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.channels is a non-empty array of known values: whatsapp, sms, push, slack, email, telegram, voice.
  • notification.delivery must be api, embedded, or hybrid.
  • If delivery is api or hybrid ⇒ notification.api.send_endpoint is required and must be HTTPS.
  • If delivery is embedded or hybrid ⇒ notification.embedded.provider_class is required (must be a valid fully-qualified PHP class name like Modules\Foo\Providers\Bar).
  • A notification block is rejected unless integrationType is set to notification.

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; body is 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), or null, in which case you decide: one you map from notification_key, or your default.
  • test: true appears 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, and templateId is always null.

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 }
        }
      ]
    }
  ]
}
  • key is what will reach you in metadata.notification_key, and variables (name → type) are exactly the names that arrive in variables on every send — your template's slots are built on them.

  • tenant is the tenant's state for the key when it has configured it (null when it has not yet): whether it is enabled, on which channels, and which of your templates it chose. default_channels is the developer's ceiling; tenant.channels is what the tenant narrowed it to.

  • ?channel=whatsapp narrows 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_apps lists every active install on the tenant with its notification count (your own provider included, at zero) — so when apps comes 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), and tenant_id in 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/rotate issues 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.


View the whole guide on one page · ↧ Raw Markdown