API & integration
Notifications catalog
Every partner app declares a notifications catalog of its own —
the list of events the app can dispatch to end users, with each event
having a stable key, semantics, variables, and default delivery
channels. Partners define the catalog from the partner dashboard;
it's shipped to okta-web on publish and surfaces on the tenant's
/settings/notifications page where they enable what they want per
installation and pick delivery channels.
Distinction from the integration type: section Notification apps above is about an integration type where the app itself is a delivery-channel provider (WhatsApp/SMS/Push). The catalog here is different: it's available to every app type (Embedded/External/Notification) to declare the catalog of events the app itself emits, regardless of which channels carry them.
Philosophy: declare-first
Your app cannot dispatch a notification from code before declaring the key in the catalog. This is enforced through two gates:
- NotificationScanner in CI: scans the codebase for any
DispatchNotification('<key>', ...)call and fails the PR when<key>isn't inmanifest.json["notifications"]. - Runtime guard on okta-web: a key the ingested catalog does not
declare is refused by name — a validation exception from
DispatchNotificationinside the module,422with{errors: {key: [...]}}onPOST /api/apps/notifications/dispatch. A declared key the school has not configured yet (an installation that predates the version adding it) is seeded for it disabled and the attempt is recorded asskipped_disabledin the delivery log. Nothing is dropped silently.
Tenants need to know upfront every notification your app might fire so they can decide whether to enable it, over which channels, and for whom.
Lifecycle
Draft version → Edit catalog freely → Submit → Approved → Published
│ │
│ ▼
│ Catalog frozen
│ Shipped to okta-web
│ Tenants can install
│
└──── Create new version ──→ Catalog cloned forward ──→ Edit freely ──→ ...
Key points:
- The catalog is bound to a version, not the module.
- Auto-clone on new version: every notification is copied forward as a starting point.
- Frozen at publish: once a version is published, its catalog becomes read-only.
- Continuous sync to GitHub: every edit updates
manifest.json["notifications"]on the version's branch.
Anatomy of a notification entry
| Field | Type | Notes |
|---|---|---|
key |
string | <your-slug>.<dot.path>. Lowercase, snake_case, 3+ parts. Immutable after creation. |
display_name_ar / display_name_en |
string | What the tenant sees. Bilingual, required — a notification with no Arabic name shows its raw key (your-app.thing_happened) inside an Arabic UI, which reads as a bug in your app. |
description_ar / description_en |
text | Optional context. |
variables_schema |
map | { name → php-type } describing the payload. |
default_template |
text | Your own default wording, with {{ variable_name }} placeholders. It is what the organisation sees (and edits from) in the customise box, and what is actually sent until they customise it. Without it the box opens empty — treat it as practically required. |
audience |
array | Who the notification is aimed at: guardian / student / staff / admin. Shown on the tenant's preferences row ("aimed at"); informational — actual routing stays with the dispatch recipient. |
default_channels |
array | Subset of email, sms, whatsapp, push, in_app, webhook_out. A ceiling: tenants narrow it and can never widen it. |
severity |
enum | info / warning / critical. |
is_active |
bool | Toggle without deleting. An inactive key disappears from the school's settings page and is not seeded for new schools; a dispatch from an installation that never configured it is refused as undeclared. A school that enabled it before the toggle keeps receiving it as long as your code dispatches it — the flag stops the spread, not the sends; stopping a feature means stopping the dispatch. |
How your template lives at the tenant
- The tenant's customise box opens on your default wording, ready to edit — never on an empty field.
- A tenant that keeps your text unedited stays inheriting it: improve the wording in a later version and every non-customising tenant gets the improvement automatically. A tenant that edited is pinned to its own text until it clears the box (clearing and saving restores inheritance).
- The tenant's test-send button renders your template with type-aware dummy values: numbers as real numbers, dates as today, strings as «variable_name» — so make sure the template reads well even with these stand-ins; they are the tenant's first impression of your message.
- If the tenant's channel provider is bound to approved templates (an
official WhatsApp number), the tenant may also pick one of the provider's
templates for this notification in the same box. Nothing for you to do:
the provider receives your notification key and your variables
(
metadata.notification_key+variables) and fills the template from them — so make your declared variables what the message text actually needs, not what is easy to compute.
How to declare
From the partner dashboard: Apps → pick your app → "Notifications"
tab. Direct URL: /dashboard/modules/<your-slug>?tab=notifications.
- Pick the version (latest draft selected by default).
- Add notification → type the key suffix (the
<slug>.prefix is added automatically), fill display name (ar/en), variables, the default message text with its placeholders, aimed at, channels, severity, save.
Dispatching from code
One call, two arguments — the key and the payload — and nothing else: no third argument, no channel inside the payload, no helper.
use App\Services\PartnerApi\Notifications\DispatchNotification;
app(DispatchNotification::class)('hr-pro.leave_request.approved', [
'recipient' => ['type' => 'host_user', 'id' => $employee->user_ulid],
'variables' => [
'employee_name' => $employee->name,
'leave_days' => 5,
'start_date' => '2026-04-26',
],
'url' => route('hr-pro.leaves.show', $leave), // optional
]);
The same call over HTTP for an External app: POST /api/apps/notifications/dispatch
with {key, payload} behind the notifications.dispatch.send scope; the
answer is 202 with delivery_ids (one delivery row per channel). Details
in the API reference.
Payload shape — these keys are reserved; any other top-level key is
moved under variables for you:
| Key | Type | What it does |
|---|---|---|
recipient |
{type, id} |
The only address the platform reads. parent_of_student (id = the student's ulid — the platform resolves the active guardians), host_user (a user id or ulid), school_admin (no id — the school's admins). |
variables |
map | What the template's {{ placeholders }} resolve to. |
subject / body |
string | Ready-made wording, used only when the school has no custom text and you shipped no default_template. |
url |
string | Where the notification opens (in_app / push). |
locale |
string | Display locale when it differs from the school's. |
metadata |
map | Stored on the delivery row, never shown. |
The rule that cost the most: a student_id or user_id beside the
variables is a template variable, not an address. A payload that relies
on it arrives unaddressed and falls back to the recipients the school
configured on the notification row (its admins by default) — so the office
receives what was meant for a parent, or the delivery row reads
failed: No … recipients when nothing is configured. Therefore every key
whose audience includes guardian or student always carries a
recipient; only an office-wide key (audience: admin — day closed,
weekly digest) may go out unaddressed to whoever the school configured. The
scanner warns (non-blocking) when a literal payload for such a key has none
(notification-recipient-missing).
The text a person reads is chosen by the platform in this order: the
school's custom wording → your default_template → body in the payload →
a stand-in built from the notification's name. Invest in default_template,
not in body.
When push actually arrives: when push is enabled on the school's row,
and the platform has an FCM account configured, and the recipient
has a device registered from okta-app. Any missing piece does not throw — the
delivery row reads skipped_no_transport or failed with the reason (see
"Local testing" below).
partner_notify() does not exist on the platform. An older edition of
this guide advertised it; the call fails at runtime unless your module
defines the helper itself, and the scanner flags it
(notification-helper-undefined).
A notification you can act on: recipient and url
The payload is not only template variables. Two reserved keys are read by okta-web before anything reaches a template, and they are the difference between a notification that gets read and one that gets acted on:
| Key | Shape | What it does |
|---|---|---|
recipient |
{ type, id } |
Who. Overrides the tenant's configured recipient list, because this notification is about one person, not a broadcast. |
url |
string | The action. The platform turns the notification card into a link to it. |
recipient — the actual address
partner_notify('leaves.request.submitted', [
'student_name' => $student->name,
'recipient' => ['type' => 'parent_of_student', 'id' => $student->ulid],
]);
Types that resolve:
parent_of_student+id= the student's ULID → their active guardians at this tenant (link not withdrawn, guardian not released). This is the address most school notifications want, and note it is the student's ULID, not the guardian's: your app knows the student, the platform knows who belongs to them.host_user+id= a user ULID → that user only.school_admin(noid) → the tenant's administrators.
With no recipient — or a type the platform doesn't know — routing falls
back to whatever the tenant configured under /settings/notifications
(roles plus named users). That is right for a broadcast ("exam timetable
published") and a silent bug for a personal event: "your child asked to
leave early" then reaches the tenant's whole list and never reaches the
parent.
The catalog's
audienceis not routing. It is a declaration the tenant reads before enabling the notification ("aimed at guardians"). Routing happens here, per dispatch.
url — what the reader opens
The notification card — in the notifications centre and on the guardian
portal's first screen — becomes a link when the payload carries url,
and stays a plain card without one:
partner_notify('leaves.request.submitted', [
'student_name' => $student->name,
'recipient' => ['type' => 'parent_of_student', 'id' => $student->ulid],
'subject' => 'A leave request is waiting for you',
'body' => "{$student->name} asked to leave at 11:30",
'url' => route('leaves.requests.show', $request), // ← the action
]);
Point it at the screen where the thing gets done, not your app's home
page: a parent who is told "waiting for your approval" and then dropped on
a dashboard has to find the child and then the request — the exact trip the
notification existed to remove. A relative URL (/apps/leaves/requests/42)
is enough, and preferred.
The card's text
The title comes from the catalog (display_name_ar/display_name_en
by the reader's locale), never from the payload — which is why the Arabic
name is mandatory. The body is read from payload.body, then
payload.subject, falling back to the catalog description when the app
sends neither. Send a written body: a catalog description is generic by
nature ("sent when a leave request is submitted") and names no child and no
time.
Two conditions before the card exists at all
in_appis declared indefault_channelsfor that key — a tenant can narrow your channels, never widen them.- The tenant has not switched the channel or the notification off under
/settings/notifications.
To see what actually happened: mcp__okta__delivery_stats and
webhook_deliveries read the delivery rows, and a skipped_disabled row
means the tenant turned it off — not that your dispatch failed.
The scanner
scripts/partner-policy/NotificationScanner.php collects every "used" key
from the dispatch calls — app(DispatchNotification::class)('key', …), the
fully-qualified spelling, and partner_notify('key', …) (matched so its key
counts, and flagged because the function is not on the platform) — reads the
declared keys and their audience from manifest.json, and compares:
- Used but not declared → blocking violation (fails CI).
- Declared but not used → warning (non-blocking).
partner_notify()in code → warningnotification-helper-undefined.- Guardian/student key dispatched with a literal payload and no
recipient→ warningnotification-recipient-missing— it names the payload that would reach nobody before a school does.
Run locally:
php scripts/partner-policy/check.php Modules/
Manifest contract
{
"notifications": [
{
"key": "hr-pro.leave_request.approved",
"display_name": { "ar": "تمت الموافقة على طلب الإجازة", "en": "Leave request approved" },
"description": { "ar": "يُرسَل تلقائياً ...", "en": "Sent automatically ..." },
"variables": { "employee_name": "string", "leave_days": "int" },
"default_channels": ["email", "in_app"],
"severity": "info",
"is_active": true
}
]
}
okta-web's SyncCatalogFromManifest reads this block on publish and
upserts the rows into the platform's notifications table.
Don't edit this block by hand in code. The Notifications tab writes it for you.
Who controls which channel
- Partner declares the possible channels in
default_channels. - Tenant enables a subset and picks recipients per installation.
- okta-web transports the message via the enabled channels.
Your app says dispatch(key, payload) without worrying about
delivery. okta-web handles the entire fan-out (email via Mailable,
SMS via provider, WhatsApp via template API, push via Web Push/FCM,
in_app writes to notifications table, webhook_out POSTs an
HMAC-signed envelope). Your app writes no per-channel transport
classes.
Worked example: HR app
1. Declare in the dashboard
| Key | Default channels | Severity |
|---|---|---|
hr-pro.leave_request.submitted |
in_app, email |
info |
hr-pro.leave_request.approved |
email, whatsapp, in_app |
info |
hr-pro.leave_request.rejected |
email, in_app |
warning |
hr-pro.attendance.alert |
email, in_app |
critical |
2. Dispatch from code
namespace Modules\HrPro\Services\LeaveRequests;
use App\Services\PartnerApi\Notifications\DispatchNotification;
final class ApproveLeaveRequest
{
public function __construct(private readonly DispatchNotification $notify) {}
public function __invoke(LeaveRequest $request, int $approverId): LeaveRequest
{
$request->update(['status' => 'approved', 'approved_by' => $approverId]);
($this->notify)('hr-pro.leave_request.approved', [
'recipient' => ['type' => 'host_user', 'id' => $request->employee_user_ulid],
'variables' => [
'employee_name' => $request->employee_name,
'leave_days' => $request->days_count,
'start_date' => $request->starts_at->toDateString(),
],
]);
return $request->fresh();
}
}
3. Submit, approve, publish. Tenants now see the notifications
in /settings/notifications.
Tips
- Name keys by domain, not technology.
hr-pro.leave_request.approved×hr-pro.email.sent. default_channelsis a ceiling, not a default: schools narrow it and can never widen it, so a channel you leave out can never be enabled by anyone. Declare every channel the notification legitimately fits (in_appandpushfor anything a parent reads in the app,whatsapp/smsfor what deserves a paid message) — the row is seeded disabled at the school anyway, and the paid channel remains its decision and its provider.- Use
severity=criticalsparingly — it breaks quiet-hours. - Keep
variables_schemasmall and meaningful. - Use
is_active=falseinstead of delete on a published version.
Common errors
| Error | Fix |
|---|---|
notification-key-not-declared |
Declare on the tab, pull manifest. |
notification-key-unused (warning) |
Remove key or wire the dispatch. |
cannot_edit_published |
Create new version, catalog auto-cloned. |
delete_blocked_by_installs |
Use is_active=false instead. |
key_prefix validation error |
Type only the suffix after <slug>.. |
Notification key [x] is not declared in this app's catalog (exception / 422) |
Declare the key and publish a version carrying it; make sure the school installed that version. Also raised when the key is is_active=false and the school never configured it. |
DispatchNotification called outside a partner-app context |
Build the context around the work — BootModuleContext is middleware and lives as long as the $next it is handed. |
Delivery log: failed: No … recipients |
Add recipient: {type: parent_of_student, id: <student ulid>} — a student_id beside the variables is a template variable, not an address. |
Delivery log: skipped_disabled |
Nothing in code — the school enables the key on /settings/notifications; say so in your app's guide. |
Delivery log: skipped_no_transport |
The channel has no transport on the platform (push without FCM) — nothing in code. |
notification-helper-undefined (warning) |
Replace partner_notify() with app(DispatchNotification::class)('<key>', $payload). |
notification-recipient-missing (warning) |
Add recipient; a payload built in a variable is not judged. |
Local testing
Dispatching needs an installation context, not a tinker session:
DispatchNotification reads the school and the installation from the
ModuleContext that the module.context middleware builds on your module's
routes, and refuses outside it with «outside a partner-app context». So test
from inside the app on the sandbox, one of two ways:
- From a screen of your module (a route behind
module.context), or from a queue job / mobile endpoint that builds the context itself throughBootModuleContext— wrapping the work inside it, not calling it and working afterwards. - Or with an HTTP request carrying the installation token (External apps, or any app from a terminal):
curl -X POST "<sandbox base url>/api/apps/notifications/dispatch" \
-H "Authorization: Bearer <installation token>" \
-H "Content-Type: application/json" \
-d '{"key":"hr-pro.leave_request.approved","payload":{"recipient":{"type":"host_user","id":"<user ulid>"},"variables":{"employee_name":"Sara","leave_days":3}}}'
Then read what happened — three places show the same delivery row with its status and error text:
- The school's page
/settings/notifications→ the "Delivery log" card under the table (recent days: every attempt with its channel, status and the error the channel job left). The same page has a test-send button per key that renders your template with dummy values and really sends it to a recipient the operator picks — the fastest way to learn that FCM is configured and the device registered. - From your app:
GET /api/apps/notifications/logs(scopenotifications.logs.read) withchannel/status/key/from/tofilters. - From your assistant: the MCP tool
notification_deliveries {slug}— totals by status, channel and key across every school that installed your app, then the latest failures with their text.
Statuses you will see: sent / queued delivered or waiting; failed with
the reason — No … recipients specifically means a payload without
recipient; skipped_disabled the school has not enabled the key;
skipped_quiet quiet hours; skipped_no_transport a channel with no
transport on the platform (push without FCM); skipped_plan_feature the
school's plan does not include app notifications.