Building your app
Notification apps
A notification app registers as a pluggable channel that the rest of
the platform consumes through one unified interface. You build the
provider once; the platform calls it with send(recipient, message)
regardless of the underlying medium (WhatsApp, SMS, Push, Slack, ...).
Manifest block
{
"integrationType": "notification",
"notification": {
"channels": ["whatsapp", "sms"],
"delivery": "api",
"api": {
"send_endpoint": "https://your-app.example/notifications/send",
"auth": "hmac"
},
"embedded": {
"provider_class": "Modules\\AcmeSms\\Providers\\AcmeSmsProvider"
},
"capabilities": {
"supports_templates": true,
"supports_media": false,
"supports_bulk": true,
"max_bulk_recipients": 1000
},
"settings_ui": {
"has_settings_page": true,
"livewire_component": "partner-apps.acme-sms.settings"
}
}
}
Validation rules enforced by the platform:
notification.channelsis a non-empty array of known values:whatsapp,sms,push,slack,email,telegram,voice.notification.deliverymust beapi,embedded, orhybrid.- If
deliveryisapiorhybrid⇒notification.api.send_endpointis required and must be HTTPS. - If
deliveryisembeddedorhybrid⇒notification.embedded.provider_classis required (must be a valid fully-qualified PHP class name likeModules\Foo\Providers\Bar). - A
notificationblock is rejected unlessintegrationTypeis set tonotification.
Scopes (granted automatically)
Picking integrationType=notification auto-attaches:
notifications.providers.send(required)notifications.logs.read(optional)
Path 1: delivery=api
The platform sends:
POST https://your-app.example/notifications/send
Content-Type: application/json
X-Okta-Timestamp: 1736435261
X-Okta-Signature: <hmac-sha256-hex>
X-Okta-Delivery-Id: <uuid>
{
"channel": "whatsapp",
"recipient_identifier": "+966500000000",
"message": {
"body": "Sarah was absent on 2026-09-15",
"title": "Student absent",
"template_id": "student_absent_v2",
"variables": { "student_name": "Sarah", "date": "2026-09-15" },
"media": []
},
"metadata": {
"notification_key": "okta-attendance.student_absent",
"app_slug": "okta-attendance",
"delivery_id": 48213
}
}
metadata is the notification's identity — next section. message.variables
are the catalogue variables exactly as the sending app passed them, not only
the rendered body; message.template_id is what the tenant chose, if
anything (else null).
The notification's identity — what your provider learns about every message
Why this section exists: an official WhatsApp number (Cloud API) delivers
a business-initiated message only as a template approved by Meta. A
provider that wraps every notification in one default template is stuck
there unless it knows which notification it is carrying — and nothing in
the payload used to say. Every send() call — from every channel, and from
the tenant's «send test» button — now carries three fixed keys in metadata:
| Key | Example | Meaning |
|---|---|---|
notification_key |
okta-attendance.student_absent |
The catalogue key — what you pick the right template by |
app_slug |
okta-attendance |
The sending app — not your provider |
delivery_id |
48213 |
The partner_notification_deliveries row this send belongs to — what you quote in a support ticket, or tie Meta's status callback to |
Alongside them:
variables— the catalogue variables as the app sent them (student_name,date, …), not only the rendered body. Fill the template's slots from these;bodyis the ready text for a provider without templates, or for a generic one-slot template.templateId— the template the tenant chose for this notification from yours (next section), ornull, in which case you decide: one you map fromnotification_key, or your default.test: trueappears only when the call comes from the tenant's test button; the three identity keys arrive with it in full, so pick for the test what you would pick for the real send — a test that succeeds on a default template while the real send fails on an unapproved one is exactly what this section exists to prevent.
A trap worth its own line: on the queue worker your provider runs inside
its own installation context. Ask AppContextManager «which app am I?»
and you hear your own name, not the sender's. The sender's name reaches
you in metadata.app_slug only.
The raw POST /api/apps/notifications/send path (an app sending through you
directly, with no catalogue) carries app_slug and recipient_type only —
there is no key and no delivery row there.
Approved templates the tenant picks — supports_templates
If your provider declares notification.capabilities.supports_templates: true,
the tenant's «customise message» modal on /settings/notifications shows one
more field per notification: approved provider template. The choice
reaches you on every send as NotificationPayload::$templateId
(message.template_id on the api wire).
-
An embedded provider can list its templates so the field is a picker, by implementing a second, optional contract beside
PartnerNotificationProvider:use App\Contracts\ListsNotificationTemplates; final class WaCloudProvider implements PartnerNotificationProvider, ListsNotificationTemplates { /** @return list<array{id: string, name: string, description?: ?string}> */ public function templates(string $channel): array { // The number's templates approved at Meta — called inside YOUR // installation context, so the tenant's number is the tenant's. // Never throw; return [] when you cannot answer. return $this->meta->approvedTemplates($channel); } }The list is closed: an id outside it is refused at save, so no non-existent template ever reaches Meta.
-
An api provider has no class here, so the tenant types the template id exactly as you know it.
-
No
supports_templates⇒ no field, andtemplateIdis alwaysnull.
Your own logic does not change because the choice exists: templateId
first when it arrives, else whatever you map from notification_key, else
your default.
The installed apps' notification catalogue — your source for building templates
An approved template is built before the first send, and you cannot
build one for a key you do not know. So a provider has an endpoint that
reads the notification catalogue of every app installed on the tenant it
is installed in — behind its own scope, notifications.providers.send, with
nothing extra to declare:
GET /api/apps/notifications/catalog?channel=whatsapp
Authorization: Bearer <installation token>
{
"tenant_id": 4821,
"apps": [
{
"app_slug": "okta-attendance",
"display_name": "Okta Attendance",
"installation_id": 918,
"notifications": [
{
"key": "okta-attendance.student_absent",
"display_name_ar": "غياب طالب",
"display_name_en": "Student absent",
"audience": ["guardian"],
"variables": { "student_name": "string", "date": "date" },
"default_template": "{{ student_name }} was absent on {{ date }}",
"default_channels": ["whatsapp", "in_app"],
"tenant": { "enabled": true, "channels": ["whatsapp"], "template_id": "student_absent_v2", "has_custom_template": false }
}
]
}
]
}
-
keyis what will reach you inmetadata.notification_key, andvariables(name → type) are exactly the names that arrive invariableson every send — your template's slots are built on them. -
tenantis the tenant's state for the key when it has configured it (nullwhen it has not yet): whether it is enabled, on which channels, and which of your templates it chose.default_channelsis the developer's ceiling;tenant.channelsis what the tenant narrowed it to. -
?channel=whatsappnarrows to the keys that would actually go out on that channel here (the tenant's choice when it has one, else the developer's ceiling) — that is the list that needs a template per entry. -
This tenant's apps only, active declarations only; no recipients, no payloads, no delivery log of any app but yours.
-
installed_appslists every active install on the tenant with its notification count (your own provider included, at zero) — so whenappscomes back empty, read it: installs with no notifications mean the apps declare none; an empty list means your token is on another tenant (a partner has one sandbox per tenant type — school, daycare… — each with its own installs), andtenant_idin the response is what you compare with the tenant open in your browser. -
A token on the sandbox: installing there issues no token by itself. From the sandbox admin account,
POST /api/store/modules/{slug}/installation-token/rotateissues the install's first token (with its approved scopes) and returns it once — or use the in-process call below, which needs no token. -
An embedded provider reads it without HTTP — the same class from your own code; it lives under
App\Services\PartnerApi\*, so the policy scanner allows the import:use App\Services\PartnerApi\Notifications\Catalog\GetInstalledAppsNotificationCatalog; // On your settings pages (the middleware built your installation context) // or inside send() on the worker (you arrive wrapped in it) — tenant and // installation come from the context, never from an argument. $catalog = app(GetInstalledAppsNotificationCatalog::class)('whatsapp'); foreach ($catalog['apps'] as $app) { foreach ($app['notifications'] as $n) { // $n['key'] is what reaches you in metadata.notification_key; // $n['variables'] names your template's slots. $this->ensureTemplateFor($n['key'], array_keys($n['variables'])); } }Outside any installation context (from tinker, say) it refuses with
no_app_context— it never answers for an unknown tenant.
The catalogue changes as apps publish new versions, so re-read it periodically or on the first unknown key that reaches you — never cache it forever.
Verify the signature:
$expected = hash_hmac('sha256', $timestamp . '.' . $body, $signingSecret);
if (! hash_equals($expected, $signature)) abort(401);
if (abs(time() - (int) $timestamp) > 300) abort(401);
Expected response: 200 with { "provider_id": "..." }. 4xx =
permanent failure, no retry. 5xx = transient failure, retried
once.
Path 2: delivery=embedded
Ship a class implementing the contract:
namespace Modules\AcmeSms\Providers;
use App\Contracts\PartnerNotificationProvider;
use App\Services\PartnerApi\Notifications\NotificationPayload;
use App\Services\PartnerApi\Notifications\NotificationResult;
final class AcmeSmsProvider implements PartnerNotificationProvider
{
public function send(NotificationPayload $payload): NotificationResult
{
try {
// Which notification, from which app, which delivery row — see «identity».
$key = $payload->metadata['notification_key'] ?? null; // okta-attendance.student_absent
$template = $payload->templateId // the tenant's choice, if any
?? $this->templateFor($key); // else what you map from the key
$response = SmsClient::send([
'to' => $payload->recipientIdentifier,
'text' => $payload->body,
'template' => $template,
'params' => $payload->variables, // catalogue variables fill the slots
'reference' => $payload->metadata['delivery_id'] ?? null,
]);
return NotificationResult::success(providerId: $response['id'] ?? null);
} catch (\Throwable $e) {
return NotificationResult::failure($e->getMessage());
}
}
public function supports(string $channel): bool { return $channel === 'sms'; }
public function isConfigured(): bool { return ! empty(config('acme-sms.api_key')); }
}
Path 3: delivery=hybrid
The platform builds a HybridNotificationProvider that tries the
embedded path first and falls back to api on isConfigured()=false
or send() returning a failure. Useful for canary releases.
Settings UI (settings_ui) — optional
If your app needs an in-tenant settings page, declare it in the
manifest. The platform mounts your registered Livewire component
under /partner-apps/notification/providers.
Per-version overrides
Every notification field above is overridable per version from the Integration tab. Blank fields inherit module-level values at manifest-build time.