Push (REST)

Manage push campaigns, credentials, devices, and webhooks. Except for the transactional endpoint, the routes on this page are dashboard APIs authenticated with a WorkOS access token and the documented workspace role. Transactional sends use a scoped backend-only server key.

Campaigns

GET    /v1/apps/:appId/campaigns?type=push
POST   /v1/apps/:appId/campaigns
PATCH  /v1/apps/:appId/campaigns/:campaignId
DELETE /v1/apps/:appId/campaigns/:campaignId

Create

{
  "name": "Continue watching",
  "mode": "transactional",
  "status": "draft",
  "variants": [
    {
      "title": "{{p.first_name}}, continue {{p.show_title}}",
      "body": "Your next scene is waiting.",
      "weight": 1,
      "openAction": {
        "action": "json",
        "actionJson": {
          "type": "open_show",
          "show_id": "{{p.show_id}}",
          "position_seconds": "{{p.position_seconds}}"
        }
      },
      "personalization": {
        "version": 1,
        "missingData": "skip",
        "sources": [
          { "id": "profile", "label": "User profile", "kind": "user_property" },
          { "id": "movie", "label": "This send's movie", "kind": "send_data" }
        ],
        "bindings": [
          {
            "id": "first_name", "label": "First name", "sourceId": "profile",
            "key": "first_name", "type": "string", "fallback": "there"
          },
          {
            "id": "show_title", "label": "Show title", "sourceId": "movie",
            "key": "show_title", "type": "string"
          },
          {
            "id": "show_id", "label": "Show ID", "sourceId": "movie",
            "key": "show_id", "type": "string"
          },
          {
            "id": "position_seconds", "label": "Position", "sourceId": "movie",
            "key": "position_seconds", "type": "number"
          }
        ]
      }
    }
  ]
}

Every {{p.binding_id}} token references a binding in that variant's personalization specification. The example uses a saved profile name and a backend-selected movie supplied with each transactional send. A whole-value JSON token preserves its declared type: show ID stays a string and position becomes a number. Configure the host's JSON action handler to open the movie.

Other source kinds include trigger_event and latest_event; latest activity also requires eventName and lookbackDays (1–90), with optional typed filters. Reuse one source ID for related movie fields. A missing required binding skips delivery; explicit fallbacks handle missing values, while type mismatches remain errors. See Personalize messages.

Send a transactional campaign

POST /v1/apps/:appId/push/transactional
Authorization: Bearer rtk_your_server_key
Content-Type: application/json
 
{
  "campaignId": "1ac74fd0-a8ad-4e0b-b244-4493cd56567c",
  "externalId": "user_42",
  "data": {
    "show_id": "00123",
    "show_title": "Midnight Orbit",
    "position_seconds": 1234
  },
  "idempotencyKey": "resume-user-42-session-1042"
}

Use a backend-only server key with the push.transactional scope. Exactly one of externalId or anonymousId is required. Reusing an idempotency key for the same app returns the original delivery instead of enqueueing a duplicate. The campaign must belong to the app, use transactional mode, and be active.

The optional data object supplies flat string, finite number, boolean or null values to Transactional send data (send_data) fields. Sensitive values still follow the app's privacy settings. Supply values for every required send-data binding; selecting that source does not populate it from event history. Use a new idempotency key for a new logical send. The queued response does not confirm APNs/FCM receipt. Recipient snapshots keep resolved content stable across retries.

To test a draft from the dashboard API, call POST /v1/apps/:appId/campaigns/:campaignId/test-send with an anonymousId. That test endpoint does not accept transactional data; use a profile/latest-event test campaign, or preview supplied example data through the personalization API.

Preview personalized content

POST /v1/apps/:appId/personalization/preview uses dashboard authentication and requires viewer access to the app. Send surface: "push", the variant as content, and one subject reference (anonymousId, externalId or subjectId). Optionally include triggerEventId for that user's event, or data for a transactional preview. Preview resolves content without sending or consuming frequency limits; the actual send can resolve newer values. The dashboard's Preview as user supports recipient selection and a triggering event ID.

Credentials

GET    /v1/apps/:appId/push/credentials
POST   /v1/apps/:appId/push/credentials             # upload .p8 or FCM JSON
DELETE /v1/apps/:appId/push/credentials/:credId

Apple (.p8)

{
  "platform": "ios",
  "p8": "-----BEGIN PRIVATE KEY-----\n…",
  "keyId": "ABCD1234EF",
  "teamId": "0123456789",
  "bundleId": "com.acme.app",
  "environment": "production"
}

Android (FCM)

{
  "platform": "android",
  "serviceAccountJson": "{ ...the complete service-account JSON... }"
}

Devices

GET  /v1/apps/:appId/users/:anonymousId/push
POST /v1/apps/:appId/users/:anonymousId/push/test-send

The first route returns consent and registered-device metadata for the full linked subject. The second sends an ad-hoc test notification and accepts title, body, and optional imageUrl and deepLink fields.

Channels

GET    /v1/apps/:appId/push/channels
PUT    /v1/apps/:appId/push/channels
DELETE /v1/apps/:appId/push/channels/:channelId

The upsert body uses channelId, displayName, numeric importance, and the optional defaultSound, defaultVibrate, defaultBadge, description, and category settings.

Webhooks

GET    /v1/apps/:appId/push/webhooks
POST   /v1/apps/:appId/push/webhooks       { "url": "https://hooks.acme.com/push", "eventTypes": ["delivered", "failed"] }
PATCH  /v1/apps/:appId/push/webhooks/:webhookId
DELETE /v1/apps/:appId/push/webhooks/:webhookId
POST   /v1/apps/:appId/push/webhooks/:webhookId/rotate
POST   /v1/apps/:appId/push/webhooks/:webhookId/test

Creation returns the HMAC secret once. Store it immediately and verify every delivery signature; rotating returns a replacement secret once.

See API → Webhooks for the outbound payload schema.

What's next