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:

  1. NotificationScanner in CI: scans the codebase for any DispatchNotification('<key>', ...) call and fails the PR when <key> isn't in manifest.json["notifications"].
  2. Runtime guard on okta-web: a key the ingested catalog does not declare is refused by name — a validation exception from DispatchNotification inside the module, 422 with {errors: {key: [...]}} on POST /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 as skipped_disabled in 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 (no id) → 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 audience is 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

  1. in_app is declared in default_channels for that key — a tenant can narrow your channels, never widen them.
  2. 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 → warning notification-helper-undefined.
  • Guardian/student key dispatched with a literal payload and no recipient → warning notification-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_channels is 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_app and push for anything a parent reads in the app, whatsapp/sms for 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=critical sparingly — it breaks quiet-hours.
  • Keep variables_schema small and meaningful.
  • Use is_active=false instead 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 through BootModuleContext — 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 (scope notifications.logs.read) with channel / status / key / from / to filters.
  • 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.


View the whole guide on one page · ↧ Raw Markdown