Notify docs
API reference

Notify API.

The management API your server, the console and the MCP server use, and the client API browsers and apps call with a recipient token. Every endpoint, request and response, generated from the live OpenAPI spec.

Base URL

https://api.notify.productcraft.co

Auth

Authorization: Bearer …

Spec version

OpenAPI 3.0.0 · v0.1.0

Categories

Groups of notification types that recipients opt in or out of together, with a default per channel (`on`, `off` or `mandatory`).

get/v1/workspaces/{workspace_id}/categoriesAuth

List categories

By `ordinal`, then oldest first.

Path parameters

workspace_id*string · uuid

Id of the workspace.

Query parameters

cursorstring

Opaque cursor: the `next_cursor` of the previous page.

limitinteger

Page size.

Response · 200 A page of categories.

data*array

Categories by `ordinal`, then oldest first.

pagination*object

The cursor for the next page.

Example

Request

GET https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/categories
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "key": "security",
      "name": "string",
      "description": "string",
      "defaults": {},
      "ordinal": 0,
      "android_channel_id": "string",
      "transactional": false,
      "created_at": "2026-01-01T00:00:00.000Z",
      "updated_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "pagination": {}
}
post/v1/workspaces/{workspace_id}/categoriesAuth

Create a category

At most 50 categories per workspace during the preview. Channels left out of `defaults` are `on`.

Path parameters

workspace_id*string · uuid

Id of the workspace.

Headers

Idempotency-Keystring

A key (1..256 chars, [A-Za-z0-9_\-:]) makes retries safe: a replay returns the original response for 24 h.

Request body

namestring

Display name, shown in preference screens.

Example: "Security"

descriptionstring

What the category covers.

defaultsobject

Default per channel; a channel left out is `on`.

ordinalinteger

Sort order in preference screens (lower first).

android_channel_idstring

Android notification channel id for FCM pushes of this category; null means the category key.

transactionalboolean

Transactional categories (receipts, security notices) send email without a List-Unsubscribe header.

key*string

Stable key, unique in the workspace: must start with a-z and use only a-z 0-9 _ . - (at most 100 characters).

Example: "security"

Response · 201 Created.

id*string · uuid

Category id.

key*string

Stable key.

Example: "security"

name*string

Display name.

description*string

Description.

defaults*object

Default per channel.

ordinal*integer

Sort order.

android_channel_id*string

Android notification channel id (the key unless set).

transactional*boolean

Whether email goes out without List-Unsubscribe.

created_at*string · date-time

Created.

updated_at*string · date-time

Last change.

Example

Request

POST https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/categories
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "name": "Security",
  "description": "string",
  "defaults": {},
  "ordinal": 0,
  "android_channel_id": "string",
  "transactional": false,
  "key": "security"
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "key": "security",
  "name": "string",
  "description": "string",
  "defaults": {},
  "ordinal": 0,
  "android_channel_id": "string",
  "transactional": false,
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
get/v1/workspaces/{workspace_id}/categories/{category_id}Auth

Get a category

Path parameters

workspace_id*string · uuid

Id of the workspace.

category_id*string · uuid

Id of the category.

Response · 200 The category.

id*string · uuid

Category id.

key*string

Stable key.

Example: "security"

name*string

Display name.

description*string

Description.

defaults*object

Default per channel.

ordinal*integer

Sort order.

android_channel_id*string

Android notification channel id (the key unless set).

transactional*boolean

Whether email goes out without List-Unsubscribe.

created_at*string · date-time

Created.

updated_at*string · date-time

Last change.

Example

Request

GET https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/categories/{category_id}
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "key": "security",
  "name": "string",
  "description": "string",
  "defaults": {},
  "ordinal": 0,
  "android_channel_id": "string",
  "transactional": false,
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
patch/v1/workspaces/{workspace_id}/categories/{category_id}Auth

Update a category

Changes the fields you send. `defaults` merges per channel. The key cannot change.

Path parameters

workspace_id*string · uuid

Id of the workspace.

category_id*string · uuid

Id of the category.

Request body

namestring

Display name, shown in preference screens.

Example: "Security"

descriptionstring

What the category covers.

defaultsobject

Default per channel; a channel left out is `on`.

ordinalinteger

Sort order in preference screens (lower first).

android_channel_idstring

Android notification channel id for FCM pushes of this category; null means the category key.

transactionalboolean

Transactional categories (receipts, security notices) send email without a List-Unsubscribe header.

Response · 200 Updated.

id*string · uuid

Category id.

key*string

Stable key.

Example: "security"

name*string

Display name.

description*string

Description.

defaults*object

Default per channel.

ordinal*integer

Sort order.

android_channel_id*string

Android notification channel id (the key unless set).

transactional*boolean

Whether email goes out without List-Unsubscribe.

created_at*string · date-time

Created.

updated_at*string · date-time

Last change.

Example

Request

PATCH https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/categories/{category_id}
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "name": "Security",
  "description": "string",
  "defaults": {},
  "ordinal": 0,
  "android_channel_id": "string",
  "transactional": false
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "key": "security",
  "name": "string",
  "description": "string",
  "defaults": {},
  "ordinal": 0,
  "android_channel_id": "string",
  "transactional": false,
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
delete/v1/workspaces/{workspace_id}/categories/{category_id}Auth

Delete a category

Only when no notification type uses it. Recipients' preferences for the category are deleted with it.

Path parameters

workspace_id*string · uuid

Id of the workspace.

category_id*string · uuid

Id of the category.

Response · 204

Deleted.

Example

Request

DELETE https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/categories/{category_id}
Authorization: Bearer YOUR_TOKEN

Channels

Which channels can deliver now, and their credentials (metadata only; secrets are write-only). Web push uses a VAPID key pair that Notify generates for you, or that you import from another provider.

get/v1/workspaces/{workspace_id}/channelsAuth

List channels

Whether each channel (inbox, email, push, web push) can deliver now, and the metadata of its credentials. Never returns a secret: credentials show a fingerprint only.

Path parameters

workspace_id*string · uuid

Id of the workspace.

Response · 200 Every channel.

data*array

Every channel, in a fixed order: inbox, email, push, webpush.

Example

Request

GET https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/channels
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "channel": {},
      "configured": false,
      "status": {},
      "credentials": [
        {
          "id": "00000000-0000-0000-0000-000000000000",
          "provider": {},
          "app_id": "string",
          "environment": {},
          "origin": {},
          "fingerprint": "string",
          "status": {},
          "public_key": "string",
          "key_id": "string",
          "team_id": "string",
          "client_email": "user@example.com",
          "last_verified_at": "2026-01-01T00:00:00.000Z",
          "last_error_code": "string",
          "created_at": "2026-01-01T00:00:00.000Z",
          "superseded_at": "2026-01-01T00:00:00.000Z"
        }
      ]
    }
  ]
}
get/v1/workspaces/{workspace_id}/channels/webpushAuth

Get the web push public key

The VAPID public key browsers subscribe with (`applicationServerKey`), or `configured: false` before one is generated or imported.

Path parameters

workspace_id*string · uuid

Id of the workspace.

Response · 200 The web push channel.

configured*boolean

Whether the workspace has an active VAPID key (web push can send).

public_key*string

The public key for `applicationServerKey`; null until a key is generated or imported.

credential_id*string · uuid

The credential id of the active key.

origin*object

How the active key was created (`generated` or `imported`).

fingerprint*string

Fingerprint of the active private key.

created_at*string · date-time

When the active key was created.

Example

Request

GET https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/channels/webpush
Authorization: Bearer YOUR_TOKEN

Response

{
  "configured": false,
  "public_key": "string",
  "credential_id": "00000000-0000-0000-0000-000000000000",
  "origin": {},
  "fingerprint": "string",
  "created_at": "2026-01-01T00:00:00.000Z"
}
post/v1/workspaces/{workspace_id}/channels/webpush/vapid-keysAuth

Generate the web push VAPID key

Generates a P-256 key pair for web push (RFC 8292) and returns its public key; the private key is stored encrypted and never returned. If the workspace already has a key, this rotates it and needs `confirm: "rotate"`: every existing browser subscription stops working and every web push device is marked `invalid` (`vapid_rotated`) until the browser subscribes again with the new key.

Path parameters

workspace_id*string · uuid

Id of the workspace.

Headers

Idempotency-Keystring

A key (1..256 chars, [A-Za-z0-9_\-:]) makes retries safe: a replay returns the original response for 24 h.

Request body

confirmenum (1)

Required when the workspace already has a VAPID key: replacing it orphans every existing browser subscription (they were made with the old public key). Every web push device is marked `invalid` (`vapid_rotated`) and must subscribe again with the new key; the `@productcraft/notify-js` service worker does this on `pushsubscriptionchange`.

Response · 201 The new active key.

credential_id*string · uuid

The credential id of the key pair.

public_key*string

The public key (base64url, uncompressed P-256). Pass it as `applicationServerKey` to `pushManager.subscribe`.

origin*object

`generated` by Notify, or `imported` from another provider.

fingerprint*string

First 16 hex characters of the SHA-256 of the private key; the private key itself is never returned.

created_at*string · date-time

When this key became active.

devices_invalidated*integer

Web push devices marked `invalid` (`vapid_rotated`) because this call replaced the previous key; 0 when nothing was replaced.

Example

Request

POST https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/channels/webpush/vapid-keys
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "confirm": "rotate"
}

Response

{
  "credential_id": "00000000-0000-0000-0000-000000000000",
  "public_key": "string",
  "origin": {},
  "fingerprint": "string",
  "created_at": "2026-01-01T00:00:00.000Z",
  "devices_invalidated": 0
}
put/v1/workspaces/{workspace_id}/channels/webpush/vapid-keysAuth

Import a web push VAPID key

Imports an existing VAPID key pair (for example from another push provider) so subscriptions made with it keep working. The pair is checked to be a matching P-256 pair. The private key is stored encrypted and never returned. Replacing a different existing key needs `confirm: "rotate"` (see generate); importing the active pair again changes nothing.

Path parameters

workspace_id*string · uuid

Id of the workspace.

Request body

public_key*string

The VAPID public key: an uncompressed P-256 point (65 bytes), base64url. The `applicationServerKey` your existing subscriptions were made with.

Example: "BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U"

private_key*string

The VAPID private key: the 32-byte P-256 scalar, base64url. Write-only: stored encrypted and never returned.

confirmenum (1)

Required when the workspace already has a different VAPID key (see `POST /channels/webpush/vapid-keys`). Importing the pair that is already active changes nothing.

Response · 200 The active key.

credential_id*string · uuid

The credential id of the key pair.

public_key*string

The public key (base64url, uncompressed P-256). Pass it as `applicationServerKey` to `pushManager.subscribe`.

origin*object

`generated` by Notify, or `imported` from another provider.

fingerprint*string

First 16 hex characters of the SHA-256 of the private key; the private key itself is never returned.

created_at*string · date-time

When this key became active.

devices_invalidated*integer

Web push devices marked `invalid` (`vapid_rotated`) because this call replaced the previous key; 0 when nothing was replaced.

Example

Request

PUT https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/channels/webpush/vapid-keys
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "public_key": "BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U",
  "private_key": "string",
  "confirm": "rotate"
}

Response

{
  "credential_id": "00000000-0000-0000-0000-000000000000",
  "public_key": "string",
  "origin": {},
  "fingerprint": "string",
  "created_at": "2026-01-01T00:00:00.000Z",
  "devices_invalidated": 0
}
put/v1/workspaces/{workspace_id}/channels/apns/credentialsAuth

Upload an APNs key

Uploads an APNs authentication key (`.p8`, token auth) for one bundle id. You still create the key in your Apple developer account; Notify validates it with Apple before activating it (a background push to a placeholder token that reaches no device), stores it encrypted and never returns it. An upload replaces the active or invalid key of the same bundle id whose environment overlaps (`replaced_credential_ids`), and resumes push for that bundle id if Apple had refused the previous key. Uploading the active key again unchanged only re-validates it. Nothing is stored when Apple refuses the key or cannot be reached.

Path parameters

workspace_id*string · uuid

Id of the workspace.

Headers

Idempotency-Keystring

A key (1..256 chars, [A-Za-z0-9_\-:]) makes retries safe: a replay returns the original response for 24 h.

Request body

key_id*string

The 10-character key id Apple shows next to the key (Certificates, Identifiers & Profiles > Keys).

Example: "ABC123DEFG"

team_id*string

The 10-character team id of the Apple developer account the key belongs to.

Example: "DEF123GHIJ"

bundle_id*string

The app bundle id the key sends to (the `apns-topic`). Devices register with the same value as `app_id`.

Example: "com.example.app"

environment*object

Which APNs environment the key serves. Token-auth keys work in both; `both` lets debug builds (`sandbox` device tokens) and TestFlight/App Store builds (`production`) share one key.

p8*string

The full contents of the `.p8` file, including the `-----BEGIN PRIVATE KEY-----` lines (`\n` escapes are accepted). Write-only: validated with Apple, stored encrypted and never returned; responses show a fingerprint.

Response · 200 The active key for the bundle id (metadata only).

credential_id*string · uuid

The credential id.

key_id*string

APNs key id.

team_id*string

Apple team id.

bundle_id*string

The bundle id the key sends to.

environment*object

The environments the key serves.

fingerprint*string

First 16 hex characters of the SHA-256 of the key; the key itself is never returned.

status*object

`active` after a successful upload; `invalid` once Apple refused it while sending.

last_verified_at*string · date-time

When Apple last accepted the key (the upload validation).

last_error_code*string

The Apple reason that made the key `invalid` (e.g. `InvalidProviderToken`).

created_at*string · date-time

When the credential was created.

replaced_credential_ids*array

APNs credentials of the same bundle id with an overlapping environment that this upload replaced (now `superseded`; their key is erased after 24 h). Empty on a first upload or a no-op re-upload.

Example

Request

PUT https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/channels/apns/credentials
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "key_id": "ABC123DEFG",
  "team_id": "DEF123GHIJ",
  "bundle_id": "com.example.app",
  "environment": {},
  "p8": "string"
}

Response

{
  "credential_id": "00000000-0000-0000-0000-000000000000",
  "key_id": "string",
  "team_id": "string",
  "bundle_id": "string",
  "environment": {},
  "fingerprint": "string",
  "status": {},
  "last_verified_at": "2026-01-01T00:00:00.000Z",
  "last_error_code": "string",
  "created_at": "2026-01-01T00:00:00.000Z",
  "replaced_credential_ids": [
    "00000000-0000-0000-0000-000000000000"
  ]
}
delete/v1/workspaces/{workspace_id}/channels/apns/credentials/{credential_id}Auth

Delete an APNs key

Deletes an APNs credential and erases its key immediately. Push to devices of its bundle id is skipped (`credential_invalid`) until another key is uploaded.

Path parameters

workspace_id*string · uuid

Id of the workspace.

credential_id*string · uuid

Id of the credential.

Response · 204

Deleted.

Example

Request

DELETE https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/channels/apns/credentials/{credential_id}
Authorization: Bearer YOUR_TOKEN
put/v1/workspaces/{workspace_id}/channels/fcm/credentialsAuth

Upload an FCM service account

Uploads the JSON key of a Google service account for one Firebase project (FCM HTTP v1). You still create the service account and its key in your Google Cloud project; Notify validates it with Google before activating it (an OAuth token request and a `validate_only` message to a placeholder token that reaches no device), stores the private key encrypted and never returns it. Use a dedicated service account with only the "Firebase Cloud Messaging API Admin" role: the Firebase Admin SDK account (`firebase-adminsdk-*`) is accepted with a `warnings` entry because it can do far more than send push. An upload replaces the active or invalid credential of the same project (`replaced_credential_ids`) and resumes push for that project if Google had refused the previous one. Uploading the active account again unchanged only re-validates it. Nothing is stored when Google refuses the account or cannot be reached.

Path parameters

workspace_id*string · uuid

Id of the workspace.

Headers

Idempotency-Keystring

A key (1..256 chars, [A-Za-z0-9_\-:]) makes retries safe: a replay returns the original response for 24 h.

Request body

service_account*string

The full text of the service-account JSON key file (Google Cloud console > IAM & Admin > Service Accounts > Keys > Add key > JSON). Use a dedicated service account with only the "Firebase Cloud Messaging API Admin" role. Write-only: validated with Google, the private key is stored encrypted and never returned; responses show the project id, the account email and a fingerprint.

Response · 200 The active credential for the project (metadata only).

credential_id*string · uuid

The credential id.

project_id*string

The Firebase project id the account sends in. Android devices register with the same value as `app_id`.

Example: "my-app-prod"

client_email*string · email

The service account email.

Example: "notify-push@my-app-prod.iam.gserviceaccount.com"

fingerprint*string

First 16 hex characters of the SHA-256 of the private key; the key itself is never returned.

status*object

`active` after a successful upload; `invalid` once Google refused it while sending.

last_verified_at*string · date-time

When Google last accepted the account (the upload validation).

last_error_code*string

The Google error that made the credential `invalid` (e.g. `THIRD_PARTY_AUTH_ERROR`, `oauth:invalid_grant`).

created_at*string · date-time

When the credential was created.

replaced_credential_ids*array

FCM credentials of the same project that this upload replaced (now `superseded`; their key is erased after 24 h). Empty on a first upload or a no-op re-upload.

warnings*array

Advice about the uploaded account, e.g. when it is the Firebase Admin SDK account (`firebase-adminsdk-*`), which can do far more than send push. The upload is accepted either way.

Example

Request

PUT https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/channels/fcm/credentials
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "service_account": "string"
}

Response

{
  "credential_id": "00000000-0000-0000-0000-000000000000",
  "project_id": "my-app-prod",
  "client_email": "notify-push@my-app-prod.iam.gserviceaccount.com",
  "fingerprint": "string",
  "status": {},
  "last_verified_at": "2026-01-01T00:00:00.000Z",
  "last_error_code": "string",
  "created_at": "2026-01-01T00:00:00.000Z",
  "replaced_credential_ids": [
    "00000000-0000-0000-0000-000000000000"
  ],
  "warnings": [
    "string"
  ]
}
delete/v1/workspaces/{workspace_id}/channels/fcm/credentials/{credential_id}Auth

Delete an FCM service account

Deletes an FCM credential and erases its private key immediately. Push to Android devices of its project is skipped (`credential_invalid`) until another service account is uploaded.

Path parameters

workspace_id*string · uuid

Id of the workspace.

credential_id*string · uuid

Id of the credential.

Response · 204

Deleted.

Example

Request

DELETE https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/channels/fcm/credentials/{credential_id}
Authorization: Bearer YOUR_TOKEN

Client

The client API, called from your users' apps and browsers with a recipient token (`Authorization: Bearer`), never an API key: their inbox (list, counts, mark, button presses, the real-time stream), their devices and web push subscription, and their preferences. Browser calls must come from the workspace's `client_origins`.

get/v1/client/inboxAuth

List the inbox

The recipient's in-app inbox, newest first, with the unseen and unread counts for the badge. Active items by default; `archived=true` lists the archived ones. Items the sender retracted are left out.

Query parameters

statusenum (3)

`all`, or only `unread` or `unseen` items.

categorystring

Only this category (its key).

tenantstring

Only this tenant (the `tenant` of the send).

archivedboolean

`true` lists archived items instead of the active ones.

cursorstring

Opaque cursor for pagination: the `next_cursor` of the previous page.

limitinteger

Page size.

Response · 200 A page of items.

data*array

Items, newest first. Items the sender retracted are left out.

pagination*object

The cursor for the next page.

counts*object

The recipient's unseen and unread counts (badge).

Example

Request

GET https://api.notify.productcraft.co/v1/client/inbox
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "notification_id": "00000000-0000-0000-0000-000000000000",
      "type": "string",
      "category": "string",
      "tenant": "string",
      "title": "string",
      "body": "string",
      "action_url": "string",
      "avatar_url": "string",
      "actions": [
        {
          "label": "string",
          "url": "string",
          "action_key": "string"
        }
      ],
      "actor": {},
      "data": {},
      "seen_at": "2026-01-01T00:00:00.000Z",
      "read_at": "2026-01-01T00:00:00.000Z",
      "archived_at": "2026-01-01T00:00:00.000Z",
      "interacted_at": "2026-01-01T00:00:00.000Z",
      "interaction_key": "string",
      "retracted_at": "2026-01-01T00:00:00.000Z",
      "created_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "pagination": {},
  "counts": {}
}
get/v1/client/inbox/countsAuth

Get the inbox counts

Unseen and unread counts for the badge, cheap enough to poll every 30 seconds or more when the real-time stream is not available. Send the last `ETag` as `If-None-Match` to get `304` while nothing changed.

Headers

if-none-matchstring

The `ETag` of a previous response.

Response · 200 The counts (with an `ETag`).

unseen*integer

Items not yet seen.

unread*integer

Items not yet read.

Example

Request

GET https://api.notify.productcraft.co/v1/client/inbox/counts
Authorization: Bearer YOUR_TOKEN

Response

{
  "unseen": 0,
  "unread": 0
}
post/v1/client/inbox/markAuth

Mark inbox items

Marks up to 100 items (`ids`), or every item up to and including `before_id` ("mark all as read"), as `seen`, `read`, `unread`, `archived` or `unarchived`, and returns the new counts. Marking `read` also marks seen. Open streams get `item.updated` and `counts`. Safe to repeat: items already in that state do not change.

Request body

action*object

What to mark the items as.

idsarray

The items (at most 100). Give `ids` or `before_id`, not both.

before_idstring · uuid

Every item up to and including this one (item ids sort by time): "mark all as read" for what the user has loaded. Give `ids` or `before_id`, not both.

Response · 200 How many items changed, and the counts after.

updated*integer

Items that changed (an item already in that state is not counted).

counts*object

The counts after the change.

Example

Request

POST https://api.notify.productcraft.co/v1/client/inbox/mark
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "action": {},
  "ids": [
    "00000000-0000-0000-0000-000000000000"
  ],
  "before_id": "00000000-0000-0000-0000-000000000000"
}

Response

{
  "updated": 0,
  "counts": {}
}
post/v1/client/inbox/{item_id}/interactAuth

Record a press on an inbox item

Call it when the recipient presses the item or one of its buttons (`action_key`). Marks the item seen and read, records `interacted_at` and the key, and sends the `inbox_item.interacted` webhook so your backend can act on it (for example "Accept invite"). Repeating the same press changes nothing.

Path parameters

item_id*string · uuid

Id of the item.

Request body

action_keystring

The `action_key` of the button pressed; left out for a press on the item itself.

Example: "accept"

Response · 200 The item after the press.

id*string · uuid

The item id (time-ordered).

notification_id*string · uuid

The notification.

type*string

The notification type key.

category*string

The category key.

tenant*string

Tenant.

title*string

Rendered title.

body*string

Rendered body (Markdown subset).

action_url*string

Where a click goes.

avatar_url*string

Avatar URL.

actions*array

Up to 2 buttons.

actor*object

Who caused it.

data*object

The send's data, exactly as sent.

seen_at*string · date-time

Seen (the bell opened).

read_at*string · date-time

Read.

archived_at*string · date-time

Archived.

interacted_at*string · date-time

When the recipient first pressed the item or one of its buttons.

interaction_key*string

The `action_key` of the button pressed last (null for a press on the item itself).

retracted_at*string · date-time

Retracted by the sender.

created_at*string · date-time

Created.

Example

Request

POST https://api.notify.productcraft.co/v1/client/inbox/{item_id}/interact
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "action_key": "accept"
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "notification_id": "00000000-0000-0000-0000-000000000000",
  "type": "string",
  "category": "string",
  "tenant": "string",
  "title": "string",
  "body": "string",
  "action_url": "string",
  "avatar_url": "string",
  "actions": [
    {
      "label": "string",
      "url": "string",
      "action_key": "string"
    }
  ],
  "actor": {},
  "data": {},
  "seen_at": "2026-01-01T00:00:00.000Z",
  "read_at": "2026-01-01T00:00:00.000Z",
  "archived_at": "2026-01-01T00:00:00.000Z",
  "interacted_at": "2026-01-01T00:00:00.000Z",
  "interaction_key": "string",
  "retracted_at": "2026-01-01T00:00:00.000Z",
  "created_at": "2026-01-01T00:00:00.000Z"
}
post/v1/client/devicesAuth

Register this device

Registers the app's APNs or FCM token, or the browser's web push subscription, for the signed-in recipient (call it on every app start; the same token again refreshes it). A token registered to another recipient moves to this one, at most 3 times per token per UTC day; a recipient registers at most 10 times per hour. Call `DELETE /v1/client/devices/{device_id}` when the user signs out on this device. Safe to repeat, so it takes no `Idempotency-Key`.

Request body

channel*object

`apns` (Apple), `fcm` (Firebase Cloud Messaging) or `webpush` (a browser subscription).

tokenstring

The device token, for `apns` (hex) and `fcm`. Stored hashed and encrypted; responses only show its last 6 characters. Not used for `webpush`.

Example: "740f4707bebcf74f9b7c25d48e3358945f6aa01da5ddb387462c7eaf61bb78ad"

subscriptionobject

The browser push subscription, for `webpush` only.

app_id*string

The app the token belongs to: the iOS bundle id for `apns`, the Firebase project id for `fcm`, the site origin for `webpush`. Push is sent with the credential configured for this app.

Example: "com.example.app"

environmentobject

APNs only, required there: `sandbox` for development builds, `production` for TestFlight and App Store builds. A token sent to the wrong environment fails with `BadDeviceToken`.

platformobject

The operating system. Defaults to `ios` for `apns`, `android` for `fcm` and `web` for `webpush`.

timezonestring

The device's IANA time zone, used for quiet hours when the recipient has none.

Example: "Europe/Lisbon"

Response · 200 Already registered to this recipient: refreshed.

id*string · uuid

The device's id.

channel*object

The channel.

token_last6*string

The last 6 characters of the token (for `webpush`, of the endpoint). The full token is never returned.

Example: "bb78ad"

app_id*string

Bundle id, Firebase project id or site origin.

Example: "com.example.app"

environment*object

APNs environment; null for other channels.

platform*object

The operating system.

timezone*string

The device's IANA time zone.

Example: "Europe/Lisbon"

state*object

`active` receives pushes; `invalid` was rejected by the provider; `pruned` was removed (the provider reported it dead, or it was evicted as the oldest of more than 20).

state_reason*string

Why the device is not active (`evicted`, `unregistered`, `bad_device_token` …).

last_seen_at*string · date-time

Last registration or refresh.

moved_at*string · date-time

When the token last moved here from another recipient (a shared device signed in as someone else).

created_at*string · date-time

When the device was first registered.

updated_at*string · date-time

Last change.

Example

Request

POST https://api.notify.productcraft.co/v1/client/devices
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "channel": {},
  "token": "740f4707bebcf74f9b7c25d48e3358945f6aa01da5ddb387462c7eaf61bb78ad",
  "subscription": {},
  "app_id": "com.example.app",
  "environment": {},
  "platform": {},
  "timezone": "Europe/Lisbon"
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "channel": {},
  "token_last6": "bb78ad",
  "app_id": "com.example.app",
  "environment": {},
  "platform": {},
  "timezone": "Europe/Lisbon",
  "state": {},
  "state_reason": "string",
  "last_seen_at": "2026-01-01T00:00:00.000Z",
  "moved_at": "2026-01-01T00:00:00.000Z",
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
delete/v1/client/devices/{device_id}Auth

Remove this device

Call it when the user signs out on the device: pushes to it stop at once.

Path parameters

device_id*string · uuid

Id of the device.

Response · 204

Removed.

Example

Request

DELETE https://api.notify.productcraft.co/v1/client/devices/{device_id}
Authorization: Bearer YOUR_TOKEN
get/v1/client/webpush/vapid-public-keyAuth

Get the web push key

The VAPID public key the browser subscribes with (`pushManager.subscribe({ applicationServerKey })`).

Response · 200 The public key.

public_key*string

The workspace's VAPID public key (base64url): pass it as `applicationServerKey` to `pushManager.subscribe()`.

Example: "BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U"

Example

Request

GET https://api.notify.productcraft.co/v1/client/webpush/vapid-public-key
Authorization: Bearer YOUR_TOKEN

Response

{
  "public_key": "BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U"
}
get/v1/client/preferencesAuth

Get my preferences

The workspace's categories with their per-channel defaults (`mandatory` channels cannot be turned off: show them read-only), the cells the recipient set, and their time zone and quiet hours.

Response · 200 The preferences.

categories*array

The workspace's categories in display order, with their defaults.

preferences*array

Cells the recipient set (anything else follows the category defaults).

unsubscribed_all*boolean

Opted out of every non-critical notification.

timezone*string

IANA time zone.

quiet_hours*object

Quiet window.

Example

Request

GET https://api.notify.productcraft.co/v1/client/preferences
Authorization: Bearer YOUR_TOKEN

Response

{
  "categories": [
    {
      "key": "billing",
      "name": "string",
      "description": "string",
      "defaults": {}
    }
  ],
  "preferences": [
    {
      "scope": {},
      "key": "string",
      "channel": {},
      "enabled": false
    }
  ],
  "unsubscribed_all": false,
  "timezone": "string",
  "quiet_hours": {}
}
patch/v1/client/preferencesAuth

Update my preferences

Sets or (with `enabled: null`) removes individual cells, and the time zone, quiet hours and unsubscribe-all. Cells you leave out are kept, so two open tabs cannot overwrite each other. Every change is recorded as consent evidence (source `recipient_token`).

Request body

preferencesarray

Cells to set, or (with `enabled: null`) remove; other cells are kept, so two open tabs cannot overwrite each other.

unsubscribed_allboolean

Opt out of (or back into) every non-critical notification.

timezonestring

IANA time zone (quiet hours).

Example: "Europe/Lisbon"

quiet_hoursobject

Quiet window; null removes it.

Response · 200 The preferences after the change.

categories*array

The workspace's categories in display order, with their defaults.

preferences*array

Cells the recipient set (anything else follows the category defaults).

unsubscribed_all*boolean

Opted out of every non-critical notification.

timezone*string

IANA time zone.

quiet_hours*object

Quiet window.

Example

Request

PATCH https://api.notify.productcraft.co/v1/client/preferences
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "preferences": [
    {
      "scope": {},
      "key": "billing",
      "channel": {},
      "enabled": false
    }
  ],
  "unsubscribed_all": false,
  "timezone": "Europe/Lisbon",
  "quiet_hours": {}
}

Response

{
  "categories": [
    {
      "key": "billing",
      "name": "string",
      "description": "string",
      "defaults": {}
    }
  ],
  "preferences": [
    {
      "scope": {},
      "key": "string",
      "channel": {},
      "enabled": false
    }
  ],
  "unsubscribed_all": false,
  "timezone": "string",
  "quiet_hours": {}
}
get/v1/client/inbox/streamAuth

Stream inbox events

Server-Sent Events for the recipient's inbox: `counts` `{unseen, unread}` on connect and after every change, `item.created` `{item}` (with the item id as the event id), `item.updated` `{ids, action}` (`read`, `seen`, `archived`, `retracted`, …; `ids` is null for very large changes: refetch), `heartbeat` every 25 seconds, and `auth_expired` `{reason}` when the token expires (`expired`) or is revoked (`revoked`): mint a new token and reconnect; `service_disabled` means Notify was turned off for the workspace (at the next heartbeat). Send `Authorization: Bearer <token>` (use a fetch-stream reader: `EventSource` cannot send headers). Reconnect with `Last-Event-ID` to receive the items created while you were away (up to 100). Delivery is best effort: refetch the counts after a reconnect. At most 5 open streams per recipient (`429 STREAM_LIMIT`); poll `GET /v1/client/inbox/counts` instead when refused.

Headers

last-event-idstring

The id of the last `item.created` event received.

Response · 200

The event stream (`text/event-stream`).


Deliveries

One row per notification, recipient, channel and target (the inbox, an email address or a device), with its state, the reason for every skip, retry or failure, and each provider attempt.

get/v1/workspaces/{workspace_id}/deliveriesAuth

List deliveries

Newest first. Filter by `notification_id`, `recipient` (external id), `state` or `channel`. Every skip, retry and failure carries a `reason` with an `explanation`.

Path parameters

workspace_id*string · uuid

Id of the workspace.

Query parameters

notification_idstring · uuid

Only this notification.

recipientstring

Only this recipient (external id).

stateenum (10)

Only this state.

channelenum (4)

Only this channel.

cursorstring

Opaque cursor for pagination: the `next_cursor` of the previous page.

limitinteger

Page size.

Response · 200 A page of deliveries.

data*array

Deliveries, newest first.

pagination*object

The cursor for the next page.

Example

Request

GET https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/deliveries
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "notification_id": "00000000-0000-0000-0000-000000000000",
      "recipient_id": "00000000-0000-0000-0000-000000000000",
      "recipient_external_id": "string",
      "channel": {},
      "provider": "string",
      "device_id": "00000000-0000-0000-0000-000000000000",
      "target": "string",
      "fallback_step": 0,
      "is_fallback": false,
      "state": {},
      "reason": {},
      "explanation": "string",
      "attempts": 0,
      "next_attempt_at": "2026-01-01T00:00:00.000Z",
      "provider_message_id": "string",
      "provider_unique_id": "string",
      "rendered": {},
      "warnings": [
        "string"
      ],
      "sent_at": "2026-01-01T00:00:00.000Z",
      "final_at": "2026-01-01T00:00:00.000Z",
      "created_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "pagination": {}
}
get/v1/workspaces/{workspace_id}/deliveries/{delivery_id}Auth

Get a delivery

With every provider attempt (status, code, duration; no message content).

Path parameters

workspace_id*string · uuid

Id of the workspace.

delivery_id*string · uuid

The delivery id.

Response · 200 The delivery.

id*string · uuid

The delivery id.

notification_id*string · uuid

The notification.

recipient_id*string · uuid

The recipient id.

recipient_external_id*string

The recipient external id (null after an erasure).

channel*object

The channel.

provider*string

`inbox`, `mail`, `apns`, `fcm` or `webpush`.

device_id*string · uuid

The device, for push and web push.

target*string

`inbox`, `email:<address hash>` or `device:<id>`; `email:erased:<delivery id>` once the recipient was erased.

fallback_step*integer

0 for an `always` channel; n for the n-th fallback step.

is_fallback*boolean

Planned because an earlier step failed.

state*object

The state.

reason*object

Why it was skipped, held, retried or failed (`mail_refused:<code>` carries Mail's code).

explanation*string

What the reason means and how to fix it.

attempts*integer

Provider calls made.

next_attempt_at*string · date-time

Next try (retry, scheduled).

provider_message_id*string

Provider message id (APNs `apns-id`, FCM message name, Mail message id).

provider_unique_id*string

APNs `apns-unique-id` (sandbox only).

rendered*object

The push or web push title and body as sent (kept 30 days).

warnings*array

Render warnings: `template_variable_missing:<name>`, `unsafe_url:<field>`.

sent_at*string · date-time

Handed to the provider (or written to the inbox).

final_at*string · date-time

Reached its final state.

created_at*string · date-time

Planned.

attempts_log*array

Provider calls, oldest first (no PII, no provider bodies).

Example

Request

GET https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/deliveries/{delivery_id}
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "notification_id": "00000000-0000-0000-0000-000000000000",
  "recipient_id": "00000000-0000-0000-0000-000000000000",
  "recipient_external_id": "string",
  "channel": {},
  "provider": "string",
  "device_id": "00000000-0000-0000-0000-000000000000",
  "target": "string",
  "fallback_step": 0,
  "is_fallback": false,
  "state": {},
  "reason": {},
  "explanation": "string",
  "attempts": 0,
  "next_attempt_at": "2026-01-01T00:00:00.000Z",
  "provider_message_id": "string",
  "provider_unique_id": "string",
  "rendered": {},
  "warnings": [
    "string"
  ],
  "sent_at": "2026-01-01T00:00:00.000Z",
  "final_at": "2026-01-01T00:00:00.000Z",
  "created_at": "2026-01-01T00:00:00.000Z",
  "attempts_log": [
    {
      "attempt": 0,
      "outcome": "string",
      "provider_status": 0,
      "provider_code": "string",
      "retry_after_s": 0,
      "duration_ms": 0,
      "created_at": "2026-01-01T00:00:00.000Z"
    }
  ]
}

Export

The whole Notify configuration of a workspace as JSON, to keep in source control or apply to another workspace.

get/v1/workspaces/{workspace_id}/exportAuth

Export the configuration

Categories, notification types with every template, and settings, in the shapes the create and `PUT /notification-types/by-key/{key}` calls accept: apply it to another workspace to promote a setup. Recipients are exported with `GET /recipients?include=preferences`.

Path parameters

workspace_id*string · uuid

Id of the workspace.

Response · 200 The configuration.

categories*array

Every category.

notification_types*array

Every notification type with its templates.

settings*object

Workspace settings (defaults written by the settings API).

exported_at*string · date-time

When the export was made.

Example

Request

GET https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/export
Authorization: Bearer YOUR_TOKEN

Response

{
  "categories": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "key": "security",
      "name": "string",
      "description": "string",
      "defaults": {},
      "ordinal": 0,
      "android_channel_id": "string",
      "transactional": false,
      "created_at": "2026-01-01T00:00:00.000Z",
      "updated_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "notification_types": [
    {
      "key": "string",
      "name": "string",
      "description": "string",
      "category_key": "string",
      "routing": {},
      "critical": false,
      "ttl_seconds": 0,
      "default_locale": "string",
      "email_quiet_hours": false,
      "throttle": {},
      "archived_at": "2026-01-01T00:00:00.000Z",
      "templates": [
        {
          "channel": "inbox",
          "locale": "string",
          "body": {}
        }
      ]
    }
  ],
  "settings": {},
  "exported_at": "2026-01-01T00:00:00.000Z"
}

Notification-Types

What you send: a key, a category, routing (`always` channels and ordered `fallback` steps), templates per channel and locale with version history, and a preview that renders without sending.

get/v1/workspaces/{workspace_id}/notification-typesAuth

List notification types

Newest first; archived types only with `include_archived=true`.

Path parameters

workspace_id*string · uuid

Id of the workspace.

Query parameters

categorystring

Only types of this category (key).

include_archivedstring

Include archived types.

cursorstring

Opaque cursor: the `next_cursor` of the previous page.

limitinteger

Page size.

Response · 200 A page of types.

data*array

Types, newest first.

pagination*object

The cursor for the next page.

Example

Request

GET https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/notification-types
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "key": "invoice.paid",
      "name": "string",
      "description": "string",
      "category_id": "00000000-0000-0000-0000-000000000000",
      "category_key": "string",
      "routing": {},
      "critical": false,
      "ttl_seconds": 0,
      "default_locale": "string",
      "email_quiet_hours": false,
      "throttle": {},
      "archived_at": "2026-01-01T00:00:00.000Z",
      "version": 0,
      "templates": [
        {
          "channel": {},
          "locale": "pt-PT",
          "version": 0,
          "updated_at": "2026-01-01T00:00:00.000Z"
        }
      ],
      "created_at": "2026-01-01T00:00:00.000Z",
      "updated_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "pagination": {}
}
post/v1/workspaces/{workspace_id}/notification-typesAuth

Create a notification type

At most 200 types per workspace during the preview. Add templates with `PUT …/templates/{channel}/{locale}`, or create the type and its templates in one call with `PUT /notification-types/by-key/{key}`.

Path parameters

workspace_id*string · uuid

Id of the workspace.

Headers

Idempotency-Keystring

A key (1..256 chars, [A-Za-z0-9_\-:]) makes retries safe: a replay returns the original response for 24 h.

Request body

namestring

Display name.

Example: "Invoice paid"

descriptionstring

What the notification is for.

category_keystring

Key of the category the type belongs to.

Example: "billing"

routingobject

Which channels are tried, and in what order.

criticalboolean

Critical types (security notices) ignore opt-outs and quiet hours and use their own rate budget. They never bypass hard bounces, spam complaints or Mail suppression.

ttl_secondsinteger

Deliveries not sent within this many seconds expire.

default_localestring

Locale used when the recipient locale has no template (BCP 47).

Example: "en"

email_quiet_hoursboolean

Hold email during the recipient quiet hours too (push is always held, the inbox never).

throttleobject

Per-recipient cap for this type; null removes it.

key*string

Key used when sending, unique in the workspace: must start with a-z and use only a-z 0-9 _ . - (at most 100 characters).

Example: "invoice.paid"

Response · 201 Created.

id*string · uuid

Type id.

key*string

Key used when sending.

Example: "invoice.paid"

name*string

Display name.

description*string

Description.

category_id*string · uuid

Category id.

category_key*string

Category key.

routing*object

Routing.

critical*boolean

Whether the type is critical.

ttl_seconds*integer

Time to live of a delivery, in seconds.

default_locale*string

Fallback locale.

email_quiet_hours*boolean

Whether email is held during quiet hours.

throttle*object

Per-recipient cap.

archived_at*string · date-time

When archived; archived types cannot be sent.

version*integer

Increases on every change of the type.

templates*array

The templates (bodies under `…/templates/{channel}/{locale}`).

created_at*string · date-time

Created.

updated_at*string · date-time

Last change.

Example

Request

POST https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/notification-types
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "name": "Invoice paid",
  "description": "string",
  "category_key": "billing",
  "routing": {},
  "critical": false,
  "ttl_seconds": 0,
  "default_locale": "en",
  "email_quiet_hours": false,
  "throttle": {},
  "key": "invoice.paid"
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "key": "invoice.paid",
  "name": "string",
  "description": "string",
  "category_id": "00000000-0000-0000-0000-000000000000",
  "category_key": "string",
  "routing": {},
  "critical": false,
  "ttl_seconds": 0,
  "default_locale": "string",
  "email_quiet_hours": false,
  "throttle": {},
  "archived_at": "2026-01-01T00:00:00.000Z",
  "version": 0,
  "templates": [
    {
      "channel": {},
      "locale": "pt-PT",
      "version": 0,
      "updated_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
put/v1/workspaces/{workspace_id}/notification-types/by-key/{key}Auth

Create or replace a notification type by key

Types as code: sets the type and its complete template set in one transaction (the shape `GET /export` returns per type). Fields left out take their defaults; templates not listed are deleted; unchanged templates keep their version. Applying the same body again changes nothing, so the call is safe to retry without an `Idempotency-Key`. Use it to promote types from a staging workspace. An archived type stays archived.

Path parameters

workspace_id*string · uuid

Id of the workspace.

key*string

The type key.

Request body

namestring

Display name.

Example: "Invoice paid"

descriptionstring

What the notification is for.

category_keystring

Key of the category; it must exist (create categories first, or apply an export).

Example: "billing"

routingobject

Routing.

criticalboolean

Critical types (security notices) ignore opt-outs and quiet hours and use their own rate budget. They never bypass hard bounces, spam complaints or Mail suppression.

ttl_secondsinteger

Deliveries not sent within this many seconds expire.

default_localestring

Locale used when the recipient locale has no template (BCP 47).

Example: "en"

email_quiet_hoursboolean

Hold email during the recipient quiet hours too (push is always held, the inbox never).

throttleobject

Per-recipient cap for this type; null removes it.

templates*array

The complete template set: templates not listed are deleted, unchanged ones keep their version. Each `body` is kept exactly as sent.

Response · 200 Replaced (or unchanged).

id*string · uuid

Type id.

key*string

Key used when sending.

Example: "invoice.paid"

name*string

Display name.

description*string

Description.

category_id*string · uuid

Category id.

category_key*string

Category key.

routing*object

Routing.

critical*boolean

Whether the type is critical.

ttl_seconds*integer

Time to live of a delivery, in seconds.

default_locale*string

Fallback locale.

email_quiet_hours*boolean

Whether email is held during quiet hours.

throttle*object

Per-recipient cap.

archived_at*string · date-time

When archived; archived types cannot be sent.

version*integer

Increases on every change of the type.

templates*array

The templates (bodies under `…/templates/{channel}/{locale}`).

created_at*string · date-time

Created.

updated_at*string · date-time

Last change.

Example

Request

PUT https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/notification-types/by-key/{key}
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "name": "Invoice paid",
  "description": "string",
  "category_key": "billing",
  "routing": {},
  "critical": false,
  "ttl_seconds": 0,
  "default_locale": "en",
  "email_quiet_hours": false,
  "throttle": {},
  "templates": [
    {
      "channel": "inbox",
      "locale": "string",
      "body": {}
    }
  ]
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "key": "invoice.paid",
  "name": "string",
  "description": "string",
  "category_id": "00000000-0000-0000-0000-000000000000",
  "category_key": "string",
  "routing": {},
  "critical": false,
  "ttl_seconds": 0,
  "default_locale": "string",
  "email_quiet_hours": false,
  "throttle": {},
  "archived_at": "2026-01-01T00:00:00.000Z",
  "version": 0,
  "templates": [
    {
      "channel": {},
      "locale": "pt-PT",
      "version": 0,
      "updated_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
get/v1/workspaces/{workspace_id}/notification-types/{type_id}Auth

Get a notification type

Path parameters

workspace_id*string · uuid

Id of the workspace.

type_id*string · uuid

Id of the type.

Response · 200 The type.

id*string · uuid

Type id.

key*string

Key used when sending.

Example: "invoice.paid"

name*string

Display name.

description*string

Description.

category_id*string · uuid

Category id.

category_key*string

Category key.

routing*object

Routing.

critical*boolean

Whether the type is critical.

ttl_seconds*integer

Time to live of a delivery, in seconds.

default_locale*string

Fallback locale.

email_quiet_hours*boolean

Whether email is held during quiet hours.

throttle*object

Per-recipient cap.

archived_at*string · date-time

When archived; archived types cannot be sent.

version*integer

Increases on every change of the type.

templates*array

The templates (bodies under `…/templates/{channel}/{locale}`).

created_at*string · date-time

Created.

updated_at*string · date-time

Last change.

Example

Request

GET https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/notification-types/{type_id}
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "key": "invoice.paid",
  "name": "string",
  "description": "string",
  "category_id": "00000000-0000-0000-0000-000000000000",
  "category_key": "string",
  "routing": {},
  "critical": false,
  "ttl_seconds": 0,
  "default_locale": "string",
  "email_quiet_hours": false,
  "throttle": {},
  "archived_at": "2026-01-01T00:00:00.000Z",
  "version": 0,
  "templates": [
    {
      "channel": {},
      "locale": "pt-PT",
      "version": 0,
      "updated_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
patch/v1/workspaces/{workspace_id}/notification-types/{type_id}Auth

Update a notification type

Changes the fields you send; `routing` is replaced whole. The key cannot change. Changing `critical` is audited with the old and new value.

Path parameters

workspace_id*string · uuid

Id of the workspace.

type_id*string · uuid

Id of the type.

Request body

namestring

Display name.

Example: "Invoice paid"

descriptionstring

What the notification is for.

category_keystring

Key of the category the type belongs to (preferences are per category).

Example: "billing"

routingobject

Which channels are tried, and in what order.

criticalboolean

Critical types (security notices) ignore opt-outs and quiet hours and use their own rate budget. They never bypass hard bounces, spam complaints or Mail suppression.

ttl_secondsinteger

Deliveries not sent within this many seconds expire.

default_localestring

Locale used when the recipient locale has no template (BCP 47).

Example: "en"

email_quiet_hoursboolean

Hold email during the recipient quiet hours too (push is always held, the inbox never).

throttleobject

Per-recipient cap for this type; null removes it.

Response · 200 Updated.

id*string · uuid

Type id.

key*string

Key used when sending.

Example: "invoice.paid"

name*string

Display name.

description*string

Description.

category_id*string · uuid

Category id.

category_key*string

Category key.

routing*object

Routing.

critical*boolean

Whether the type is critical.

ttl_seconds*integer

Time to live of a delivery, in seconds.

default_locale*string

Fallback locale.

email_quiet_hours*boolean

Whether email is held during quiet hours.

throttle*object

Per-recipient cap.

archived_at*string · date-time

When archived; archived types cannot be sent.

version*integer

Increases on every change of the type.

templates*array

The templates (bodies under `…/templates/{channel}/{locale}`).

created_at*string · date-time

Created.

updated_at*string · date-time

Last change.

Example

Request

PATCH https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/notification-types/{type_id}
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "name": "Invoice paid",
  "description": "string",
  "category_key": "billing",
  "routing": {},
  "critical": false,
  "ttl_seconds": 0,
  "default_locale": "en",
  "email_quiet_hours": false,
  "throttle": {}
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "key": "invoice.paid",
  "name": "string",
  "description": "string",
  "category_id": "00000000-0000-0000-0000-000000000000",
  "category_key": "string",
  "routing": {},
  "critical": false,
  "ttl_seconds": 0,
  "default_locale": "string",
  "email_quiet_hours": false,
  "throttle": {},
  "archived_at": "2026-01-01T00:00:00.000Z",
  "version": 0,
  "templates": [
    {
      "channel": {},
      "locale": "pt-PT",
      "version": 0,
      "updated_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
post/v1/workspaces/{workspace_id}/notification-types/{type_id}/archiveAuth

Archive a notification type

Archived types cannot be sent (`NOTIFICATION_TYPE_ARCHIVED`); history stays. Archiving twice is a no-op.

Path parameters

workspace_id*string · uuid

Id of the workspace.

type_id*string · uuid

Id of the type.

Response · 200 Archived.

id*string · uuid

Type id.

key*string

Key used when sending.

Example: "invoice.paid"

name*string

Display name.

description*string

Description.

category_id*string · uuid

Category id.

category_key*string

Category key.

routing*object

Routing.

critical*boolean

Whether the type is critical.

ttl_seconds*integer

Time to live of a delivery, in seconds.

default_locale*string

Fallback locale.

email_quiet_hours*boolean

Whether email is held during quiet hours.

throttle*object

Per-recipient cap.

archived_at*string · date-time

When archived; archived types cannot be sent.

version*integer

Increases on every change of the type.

templates*array

The templates (bodies under `…/templates/{channel}/{locale}`).

created_at*string · date-time

Created.

updated_at*string · date-time

Last change.

Example

Request

POST https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/notification-types/{type_id}/archive
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "key": "invoice.paid",
  "name": "string",
  "description": "string",
  "category_id": "00000000-0000-0000-0000-000000000000",
  "category_key": "string",
  "routing": {},
  "critical": false,
  "ttl_seconds": 0,
  "default_locale": "string",
  "email_quiet_hours": false,
  "throttle": {},
  "archived_at": "2026-01-01T00:00:00.000Z",
  "version": 0,
  "templates": [
    {
      "channel": {},
      "locale": "pt-PT",
      "version": 0,
      "updated_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
post/v1/workspaces/{workspace_id}/notification-types/{type_id}/previewAuth

Preview a notification type

Renders every routed channel with sample `data`, or as a real `recipient` (its locale: exact, then language, then the type default; its fields as `recipient.*`). Reports missing variables per channel and variables some channels use but others do not. Sends nothing.

Path parameters

workspace_id*string · uuid

Id of the workspace.

type_id*string · uuid

Id of the type.

Request body

dataobject

Sample `data`, as a send would pass it (keys kept as sent). At most 16 KB.

Example: {"amount":"12.00","invoice_id":"inv_123"}

recipientstring

Render as this recipient (its locale chain and fields under `recipient.*`). Nothing is sent.

Example: "user_123"

localestring

Locale to render when no recipient is given (or to override it).

Example: "pt-PT"

Response · 200 The rendered channels.

channels*array

One entry per routed channel.

cross_channel_mismatches*object

Variables used by some channels but not others: variable → the channels missing it.

Example

Request

POST https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/notification-types/{type_id}/preview
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "data": {
    "amount": "12.00",
    "invoice_id": "inv_123"
  },
  "recipient": "user_123",
  "locale": "pt-PT"
}

Response

{
  "channels": [
    {
      "channel": {},
      "locale": "string",
      "rendered": {},
      "missing_variables": [
        "string"
      ],
      "warnings": [
        "string"
      ]
    }
  ],
  "cross_channel_mismatches": {}
}
get/v1/workspaces/{workspace_id}/notification-types/{type_id}/templates/{channel}/{locale}Auth

Get a template

Path parameters

workspace_id*string · uuid

Id of the workspace.

type_id*string · uuid

Id of the type.

channel*enum (4)

Template channel.

locale*string

Template locale (BCP 47).

Response · 200 The template.

channel*object

Channel.

locale*string

Locale (BCP 47).

Example: "pt-PT"

version*integer

Current version.

updated_at*string · date-time

Last change.

body*object

The channel template, field names as shown (stored and returned exactly as sent). Text fields use the Mustache subset `{{var}}`, `{{#section}}`, `{{^inverted}}` (no partials). `inbox`: `{title, body (Markdown), action_url?, avatar_url?, actions?: [{label, url? | action_key?}] (≤ 2)}`. `email`: `{mail_template_name, data_mapping?}` (the HTML lives in Mail; `data_mapping` maps Mail variables to Notify variables). `push`: `{title?, body, url?, image_url?, badge?: "unread_count" | number, sound?, thread_id?, data_only?, apns_overrides?, fcm_overrides?}`. `webpush`: `{title, body?, url?, icon?, tag?}`. URL fields must be https (http only for localhost). At most 32 KB.

variables*array

Variables the template uses.

Example

Request

GET https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/notification-types/{type_id}/templates/{channel}/{locale}
Authorization: Bearer YOUR_TOKEN

Response

{
  "channel": {},
  "locale": "pt-PT",
  "version": 0,
  "updated_at": "2026-01-01T00:00:00.000Z",
  "body": {},
  "variables": [
    "string"
  ]
}
put/v1/workspaces/{workspace_id}/notification-types/{type_id}/templates/{channel}/{locale}Auth

Create or replace a template

Sets the template of one channel and locale. Every change is kept as a version (the last 20); writing the same body again changes nothing. For `email`, the body names a Mail template (`mail_template_name`); setup status reports one that does not exist.

Path parameters

workspace_id*string · uuid

Id of the workspace.

type_id*string · uuid

Id of the type.

channel*enum (4)

Template channel.

locale*string

Template locale (BCP 47).

Request body

body*object

The channel template, field names as shown (stored and returned exactly as sent). Text fields use the Mustache subset `{{var}}`, `{{#section}}`, `{{^inverted}}` (no partials). `inbox`: `{title, body (Markdown), action_url?, avatar_url?, actions?: [{label, url? | action_key?}] (≤ 2)}`. `email`: `{mail_template_name, data_mapping?}` (the HTML lives in Mail; `data_mapping` maps Mail variables to Notify variables). `push`: `{title?, body, url?, image_url?, badge?: "unread_count" | number, sound?, thread_id?, data_only?, apns_overrides?, fcm_overrides?}`. `webpush`: `{title, body?, url?, icon?, tag?}`. URL fields must be https (http only for localhost). At most 32 KB.

Example: {"title":"Invoice {{invoice_id}} paid","body":"We received **{{amount}}**."}

Response · 200 Replaced (or unchanged).

channel*object

Channel.

locale*string

Locale (BCP 47).

Example: "pt-PT"

version*integer

Current version.

updated_at*string · date-time

Last change.

body*object

The channel template, field names as shown (stored and returned exactly as sent). Text fields use the Mustache subset `{{var}}`, `{{#section}}`, `{{^inverted}}` (no partials). `inbox`: `{title, body (Markdown), action_url?, avatar_url?, actions?: [{label, url? | action_key?}] (≤ 2)}`. `email`: `{mail_template_name, data_mapping?}` (the HTML lives in Mail; `data_mapping` maps Mail variables to Notify variables). `push`: `{title?, body, url?, image_url?, badge?: "unread_count" | number, sound?, thread_id?, data_only?, apns_overrides?, fcm_overrides?}`. `webpush`: `{title, body?, url?, icon?, tag?}`. URL fields must be https (http only for localhost). At most 32 KB.

variables*array

Variables the template uses.

Example

Request

PUT https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/notification-types/{type_id}/templates/{channel}/{locale}
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "body": {
    "title": "Invoice {{invoice_id}} paid",
    "body": "We received **{{amount}}**."
  }
}

Response

{
  "channel": {},
  "locale": "pt-PT",
  "version": 0,
  "updated_at": "2026-01-01T00:00:00.000Z",
  "body": {},
  "variables": [
    "string"
  ]
}
delete/v1/workspaces/{workspace_id}/notification-types/{type_id}/templates/{channel}/{locale}Auth

Delete a template

Deletes the template and its versions. Sends to that channel and locale fall back along the locale chain.

Path parameters

workspace_id*string · uuid

Id of the workspace.

type_id*string · uuid

Id of the type.

channel*enum (4)

Template channel.

locale*string

Template locale (BCP 47).

Response · 204

Deleted.

Example

Request

DELETE https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/notification-types/{type_id}/templates/{channel}/{locale}
Authorization: Bearer YOUR_TOKEN
get/v1/workspaces/{workspace_id}/notification-types/{type_id}/templates/{channel}/{locale}/versionsAuth

List a template's versions

The last 20 versions, newest first.

Path parameters

workspace_id*string · uuid

Id of the workspace.

type_id*string · uuid

Id of the type.

channel*enum (4)

Template channel.

locale*string

Template locale (BCP 47).

Response · 200 The versions.

data*array

The last 20 versions, newest first.

Example

Request

GET https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/notification-types/{type_id}/templates/{channel}/{locale}/versions
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "version": 0,
      "body": {},
      "actor": "string",
      "created_at": "2026-01-01T00:00:00.000Z"
    }
  ]
}
post/v1/workspaces/{workspace_id}/notification-types/{type_id}/templates/{channel}/{locale}/revertAuth

Revert a template

Restores an earlier version as a new version (audited as `notify.template.reverted`).

Path parameters

workspace_id*string · uuid

Id of the workspace.

type_id*string · uuid

Id of the type.

channel*enum (4)

Template channel.

locale*string

Template locale (BCP 47).

Request body

version*integer

The version to restore (from `…/versions`). The restore is saved as a new version.

Response · 200 The template after the revert.

channel*object

Channel.

locale*string

Locale (BCP 47).

Example: "pt-PT"

version*integer

Current version.

updated_at*string · date-time

Last change.

body*object

The channel template, field names as shown (stored and returned exactly as sent). Text fields use the Mustache subset `{{var}}`, `{{#section}}`, `{{^inverted}}` (no partials). `inbox`: `{title, body (Markdown), action_url?, avatar_url?, actions?: [{label, url? | action_key?}] (≤ 2)}`. `email`: `{mail_template_name, data_mapping?}` (the HTML lives in Mail; `data_mapping` maps Mail variables to Notify variables). `push`: `{title?, body, url?, image_url?, badge?: "unread_count" | number, sound?, thread_id?, data_only?, apns_overrides?, fcm_overrides?}`. `webpush`: `{title, body?, url?, icon?, tag?}`. URL fields must be https (http only for localhost). At most 32 KB.

variables*array

Variables the template uses.

Example

Request

POST https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/notification-types/{type_id}/templates/{channel}/{locale}/revert
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "version": 0
}

Response

{
  "channel": {},
  "locale": "pt-PT",
  "version": 0,
  "updated_at": "2026-01-01T00:00:00.000Z",
  "body": {},
  "variables": [
    "string"
  ]
}

Notifications

Send a notification to up to 1,000 recipients with one call (Idempotency-Key required), explain a send with `dry_run`, and read, cancel or retract what was sent (the message log).

get/v1/workspaces/{workspace_id}/notificationsAuth

List notifications (message log)

Newest first. Filter by `type`, `tag` (`key:value` or `key`), `tenant` or `cancellation_key`. Pass `next_cursor` as `cursor` until `has_more` is false.

Path parameters

workspace_id*string · uuid

Id of the workspace.

Query parameters

typestring

Only this notification type key.

tagstring

Only notifications with this tag: `key:value`, or `key` for any value.

tenantstring

Only this tenant.

cancellation_keystring

Only notifications sent with this cancellation key.

cursorstring

Opaque cursor for pagination: the `next_cursor` of the previous page.

limitinteger

Page size.

Response · 200 A page of notifications.

data*array

Notifications, newest first.

pagination*object

The cursor for the next page.

Example

Request

GET https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/notifications
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "type": "string",
      "status": {},
      "recipient_count": 0,
      "skipped_count": 0,
      "channels": [
        "inbox"
      ],
      "tenant": "string",
      "tags": {},
      "actor": {},
      "exclude_actor": false,
      "cancellation_key": "string",
      "idempotency_key": "string",
      "caller": "string",
      "send_at": "2026-01-01T00:00:00.000Z",
      "expires_at": "2026-01-01T00:00:00.000Z",
      "planned_at": "2026-01-01T00:00:00.000Z",
      "cancelled_at": "2026-01-01T00:00:00.000Z",
      "retracted_at": "2026-01-01T00:00:00.000Z",
      "created_at": "2026-01-01T00:00:00.000Z",
      "data": {},
      "counts": [
        {
          "channel": {},
          "states": {
            "delivered": 2,
            "skipped": 1
          }
        }
      ]
    }
  ],
  "pagination": {}
}
post/v1/workspaces/{workspace_id}/notificationsAuth

Send a notification

Notifies up to 1,000 recipients with one notification type, across the type's channels (in-app inbox, email, push, web push), honouring each recipient's preferences. Returns 202 as soon as the send is stored; delivery happens in the background (see `GET /deliveries`). Unknown external ids do not fail the call: they are listed in `skipped` (`create_missing: true` creates them instead); the call fails only when no recipient can be notified. An `Idempotency-Key` is **required** (a deterministic one such as `invoice.paid:inv_123` is best): the same key and body return the same notification, even when retried much later; the same key with a different body is a 409. `dry_run: true` returns the plan per recipient and channel with the reason for every skip, and stores nothing (200, no key needed). Limits: 3,000 recipients per minute per workspace; 30 notifications per minute per recipient (critical types have a separate 30), beyond which deliveries are skipped as `rate_limited`.

Path parameters

workspace_id*string · uuid

Id of the workspace.

Headers

Idempotency-Key*string

Required unless `dry_run` is true. 1..256 chars of [A-Za-z0-9_\-:]; the same key with the same body returns the original notification.

Request body

type*string

The notification type key.

Example: "invoice.paid"

recipients*array

Who to notify: up to 1,000 external ids, or objects with an `external_id` and profile fields (created or updated with the send). Unknown ids do not fail the call: they come back in `skipped` (pass `create_missing: true` to create bare recipients instead).

Example: ["user_123",{"external_id":"user_456","email":"a@example.com","locale":"pt-PT"}]

dataobject

Template variables, exactly as sent (keys are not re-cased). At most 16 KB as JSON.

Example: {"amount":"12.00","invoice_url":"https://example.com/i/123"}

channelsarray

Only these channels (a subset of the type's routing).

actorobject

Who caused the notification.

exclude_actorboolean

Leave the actor out of the recipients (default true when `actor.external_id` is set).

send_atstring · date-time

Send at this time instead of now (UTC, ISO 8601), at most the type TTL ahead. Not with `delay_seconds`.

delay_secondsinteger

Send this many seconds from now (at most the type TTL). Not with `send_at`.

cancellation_keystring

Your key for cancelling later without storing our id (`POST /notifications/cancel`).

Example: "invite:inv_77"

tenantstring

Your tenant (organisation, team): inbox items can be filtered by it.

Example: "org_42"

tagsobject

Up to 10 string pairs, searchable in the message log (`GET /notifications?tag=key:value`). Keys: 1-64 of `A-Z a-z 0-9 _ . : -`.

Example: {"invoice":"inv_123"}

create_missingboolean

Create a bare recipient for each unknown external id instead of skipping it.

dry_runboolean

Plan and explain per recipient and channel without storing or sending anything (no `Idempotency-Key` needed, no rate limit used).

Response · 200 `dry_run`: the plan; nothing stored or sent.

dry_run*boolean

Always true: nothing was stored or sent.

type*string

The notification type key.

plan*array

One row per recipient, channel and target, in recipient order.

skipped*array

Recipients that would be left out.

Example

Request

POST https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/notifications
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "type": "invoice.paid",
  "recipients": [
    "user_123",
    {
      "external_id": "user_456",
      "email": "a@example.com",
      "locale": "pt-PT"
    }
  ],
  "data": {
    "amount": "12.00",
    "invoice_url": "https://example.com/i/123"
  },
  "channels": [
    "inbox"
  ],
  "actor": {},
  "exclude_actor": false,
  "send_at": "2026-01-01T00:00:00.000Z",
  "delay_seconds": 0,
  "cancellation_key": "invite:inv_77",
  "tenant": "org_42",
  "tags": {
    "invoice": "inv_123"
  },
  "create_missing": false,
  "dry_run": false
}

Response

{
  "dry_run": false,
  "type": "string",
  "plan": [
    {
      "recipient": "user_123",
      "channel": {},
      "decision": {},
      "reason": {},
      "explanation": "string",
      "target": "string",
      "fallback_step": 0,
      "not_before": "2026-01-01T00:00:00.000Z"
    }
  ],
  "skipped": [
    {
      "external_id": "user_999",
      "reason": {}
    }
  ]
}
post/v1/workspaces/{workspace_id}/notifications/cancelAuth

Cancel notifications by cancellation key

Cancels every live notification sent with this `cancellation_key`: deliveries still pending, scheduled or retrying end `skipped: cancelled`. Already delivered messages stay delivered (retract inbox items with `…/retract`). Safe to repeat.

Path parameters

workspace_id*string · uuid

Id of the workspace.

Request body

cancellation_key*string

The `cancellation_key` given at send.

Example: "invite:inv_77"

Response · 200 What was cancelled.

cancelled*array

Notifications that were cancelled (empty when none was live).

deliveries_cancelled*integer

Deliveries moved to `skipped: cancelled`.

Example

Request

POST https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/notifications/cancel
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "cancellation_key": "invite:inv_77"
}

Response

{
  "cancelled": [
    "00000000-0000-0000-0000-000000000000"
  ],
  "deliveries_cancelled": 0
}
get/v1/workspaces/{workspace_id}/notifications/{notification_id}Auth

Get a notification

With its data and the number of deliveries per channel and state.

Path parameters

workspace_id*string · uuid

Id of the workspace.

notification_id*string · uuid

The notification id.

Response · 200 The notification.

id*string · uuid

The notification id.

type*string

The notification type key.

status*object

`scheduled` (waiting for `send_at`), `accepted` (planning), `planned`, or `cancelled`.

recipient_count*integer

Recipients notified.

skipped_count*integer

Recipients left out at accept.

channels*array

The channel restriction, if the send had one.

tenant*string

Tenant.

tags*object

Tags, exactly as sent.

actor*object

Who caused it.

exclude_actor*boolean

Whether the actor was left out of the recipients.

cancellation_key*string

The cancellation key.

idempotency_key*string

The send's `Idempotency-Key`.

caller*string

Who sent it: `pak:<id>`, `platform:<user id>`.

send_at*string · date-time

When it is (or was) released.

expires_at*string · date-time

Deliveries not made by then end `failed: expired`.

planned_at*string · date-time

When every recipient was planned.

cancelled_at*string · date-time

When it was cancelled.

retracted_at*string · date-time

When its inbox items were retracted.

created_at*string · date-time

Accepted.

dataobject

The template data, exactly as sent (only on retrieve).

countsarray

Deliveries per channel and state: what became of the send (on list and retrieve).

Example

Request

GET https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/notifications/{notification_id}
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "type": "string",
  "status": {},
  "recipient_count": 0,
  "skipped_count": 0,
  "channels": [
    "inbox"
  ],
  "tenant": "string",
  "tags": {},
  "actor": {},
  "exclude_actor": false,
  "cancellation_key": "string",
  "idempotency_key": "string",
  "caller": "string",
  "send_at": "2026-01-01T00:00:00.000Z",
  "expires_at": "2026-01-01T00:00:00.000Z",
  "planned_at": "2026-01-01T00:00:00.000Z",
  "cancelled_at": "2026-01-01T00:00:00.000Z",
  "retracted_at": "2026-01-01T00:00:00.000Z",
  "created_at": "2026-01-01T00:00:00.000Z",
  "data": {},
  "counts": [
    {
      "channel": {},
      "states": {
        "delivered": 2,
        "skipped": 1
      }
    }
  ]
}
post/v1/workspaces/{workspace_id}/notifications/{notification_id}/cancelAuth

Cancel a notification

Deliveries still pending, scheduled or retrying end `skipped: cancelled`, and recipients not planned yet are never notified. Delivered messages stay (retract inbox items with `…/retract`). Safe to repeat.

Path parameters

workspace_id*string · uuid

Id of the workspace.

notification_id*string · uuid

The notification id.

Response · 200 The cancelled notification.

id*string · uuid

The notification id.

type*string

The notification type key.

status*object

`scheduled` (waiting for `send_at`), `accepted` (planning), `planned`, or `cancelled`.

recipient_count*integer

Recipients notified.

skipped_count*integer

Recipients left out at accept.

channels*array

The channel restriction, if the send had one.

tenant*string

Tenant.

tags*object

Tags, exactly as sent.

actor*object

Who caused it.

exclude_actor*boolean

Whether the actor was left out of the recipients.

cancellation_key*string

The cancellation key.

idempotency_key*string

The send's `Idempotency-Key`.

caller*string

Who sent it: `pak:<id>`, `platform:<user id>`.

send_at*string · date-time

When it is (or was) released.

expires_at*string · date-time

Deliveries not made by then end `failed: expired`.

planned_at*string · date-time

When every recipient was planned.

cancelled_at*string · date-time

When it was cancelled.

retracted_at*string · date-time

When its inbox items were retracted.

created_at*string · date-time

Accepted.

dataobject

The template data, exactly as sent (only on retrieve).

countsarray

Deliveries per channel and state: what became of the send (on list and retrieve).

Example

Request

POST https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/notifications/{notification_id}/cancel
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "type": "string",
  "status": {},
  "recipient_count": 0,
  "skipped_count": 0,
  "channels": [
    "inbox"
  ],
  "tenant": "string",
  "tags": {},
  "actor": {},
  "exclude_actor": false,
  "cancellation_key": "string",
  "idempotency_key": "string",
  "caller": "string",
  "send_at": "2026-01-01T00:00:00.000Z",
  "expires_at": "2026-01-01T00:00:00.000Z",
  "planned_at": "2026-01-01T00:00:00.000Z",
  "cancelled_at": "2026-01-01T00:00:00.000Z",
  "retracted_at": "2026-01-01T00:00:00.000Z",
  "created_at": "2026-01-01T00:00:00.000Z",
  "data": {},
  "counts": [
    {
      "channel": {},
      "states": {
        "delivered": 2,
        "skipped": 1
      }
    }
  ]
}
post/v1/workspaces/{workspace_id}/notifications/{notification_id}/retractAuth

Retract a notification from inboxes

Archives the notification's inbox items for every recipient (for example once an invite is accepted) and updates their unread counts; open inboxes drop the items. Does not cancel other channels (use `…/cancel`). Safe to repeat.

Path parameters

workspace_id*string · uuid

Id of the workspace.

notification_id*string · uuid

The notification id.

Response · 200 Retracted.

id*string · uuid

The notification id.

items_retracted*integer

Inbox items archived by this call (0 when already retracted).

Example

Request

POST https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/notifications/{notification_id}/retract
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "items_retracted": 0
}

Recipient-Tokens

Short-lived tokens your backend mints for one signed-in user, so their app or browser can call the client API without your secret key. Revoke one recipient's tokens, or every token of the workspace.

post/v1/workspaces/{workspace_id}/recipient-tokensAuth

Mint a recipient token

Returns a short-lived token (default 1 hour, at most 24 hours) that lets one recipient read their inbox, register devices, manage their preferences and open the real-time stream through the client API (`/v1/client/…`). Mint it on your backend with your API key after you have signed the user in, and hand it to the app or browser: your secret key never ships in an app. A recipient that does not exist yet is created (with the `upsert` fields if given). Limited to 3,000 mints per minute per workspace: cache a token per user for most of its lifetime. Not idempotent by design (each call returns a fresh token), so it takes no `Idempotency-Key`.

Path parameters

workspace_id*string · uuid

Id of the workspace.

Request body

recipient*string

Your user id for the recipient (`external_id`). A recipient that does not exist yet is created (bare, or with the `upsert` fields), so you can mint at sign-in without a separate call.

Example: "user_123"

scopesarray

What the token allows. Default: every scope. Narrow it for a surface that only reads the inbox, for example.

Example: ["inbox:read","stream"]

ttl_secondsinteger

Lifetime in seconds (60 to 86,400). Cache the token per user for most of its life instead of minting on every page load.

upsertobject

Profile fields to set on the recipient while minting (created or existing). Left out: the profile is not changed.

Response · 201 The token.

token*string

The recipient token (an RS256 JWT). Send it as `Authorization: Bearer <token>` to the client API (`/v1/client/…`) from the recipient's app or browser. Keep it in memory, never in a URL or browser storage.

Example: "eyJhbGciOiJSUzI1NiIsImtpZCI6InJzMjU2LWV4YW1wbGUifQ.eyJzdWIiOiIuLi4ifQ.c2lnbmF0dXJl"

expires_at*string · date-time

When the token stops working; mint a new one before then.

scopes*array

The scopes the token carries.

recipient_id*string · uuid

The recipient's Notify id.

external_id*string

Your user id for the recipient.

Example

Request

POST https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/recipient-tokens
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "recipient": "user_123",
  "scopes": [
    "inbox:read",
    "stream"
  ],
  "ttl_seconds": 0,
  "upsert": {}
}

Response

{
  "token": "eyJhbGciOiJSUzI1NiIsImtpZCI6InJzMjU2LWV4YW1wbGUifQ.eyJzdWIiOiIuLi4ifQ.c2lnbmF0dXJl",
  "expires_at": "2026-01-01T00:00:00.000Z",
  "scopes": [
    "inbox:read"
  ],
  "recipient_id": "00000000-0000-0000-0000-000000000000",
  "external_id": "string"
}
post/v1/workspaces/{workspace_id}/recipients/{external_id}/revoke-tokensAuth

Revoke a recipient's tokens

Every recipient token minted for this recipient so far stops working on its next call (`401 RECIPIENT_TOKEN_INVALID`), open real-time streams included. Tokens you mint afterwards work. Use it when the user signs out everywhere or you suspect a token leaked. Calling it again is harmless.

Path parameters

workspace_id*string · uuid

Id of the workspace.

external_id*string

Your user id for the recipient (URL-encoded): 1-255 of `A-Z a-z 0-9 . _ : @ | -`.

Response · 204

Revoked.

Example

Request

POST https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/recipients/{external_id}/revoke-tokens
Authorization: Bearer YOUR_TOKEN
post/v1/workspaces/{workspace_id}/settings/revoke-all-tokensAuth

Revoke every recipient token

Every recipient token of the workspace stops working on its next call (`401 RECIPIENT_TOKEN_INVALID`), open real-time streams included; your apps get a new token from your backend. Use it after a suspected leak of many tokens.

Path parameters

workspace_id*string · uuid

Id of the workspace.

Response · 204

Revoked.

Example

Request

POST https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/settings/revoke-all-tokens
Authorization: Bearer YOUR_TOKEN

Recipients

The people you notify, keyed by your own user id (`external_id`), with their email, phone, locale, time zone, quiet hours, your custom properties, their devices (APNs and FCM tokens, web push subscriptions) and their preferences. Erase one for GDPR with `DELETE`.

post/v1/workspaces/{workspace_id}/recipients/bulkAuth

Create or update up to 1,000 recipients

Upserts each row like `PUT /recipients/{external_id}`, with its devices. Rows are independent: a bad row is reported in `data` with `status: error` and its code, and never fails the rest. Rows are written in order of external id, in transactions of at most 200. At most 20 calls per minute per workspace (20,000 recipients) and 4 MB per request. Each row may carry its complete `preferences` set (as `PUT …/preferences`); a bad cell fails only its row. Send an `Idempotency-Key` so a retried import replays its first result.

Path parameters

workspace_id*string · uuid

Id of the workspace.

Headers

Idempotency-Keystring

A key (1..256 chars, [A-Za-z0-9_\-:]) makes retries safe: a replay returns the original result for 24 h.

Request body

recipients*array

Up to 1,000 recipients. Each row is validated and written on its own: a bad row is reported in `data` and never fails the others. A row repeating an earlier `external_id` in the same call is refused.

Response · 200 One result per row.

data*array

One result per row, in request order.

created*number

Rows created.

updated*number

Rows updated.

failed*number

Rows refused.

Example

Request

POST https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/recipients/bulk
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "recipients": [
    {
      "auth_user_id": "00000000-0000-0000-0000-000000000000",
      "email": "ada@example.com",
      "phone": "+351912345678",
      "name": "Ada Lovelace",
      "locale": "pt-PT",
      "timezone": "Europe/Lisbon",
      "quiet_hours": {},
      "properties": {
        "plan": "pro",
        "team_id": "team_42"
      },
      "external_id": "user_123",
      "devices": [
        {
          "channel": {},
          "token": "740f4707bebcf74f9b7c25d48e3358945f6aa01da5ddb387462c7eaf61bb78ad",
          "subscription": {},
          "app_id": "com.example.app",
          "environment": {},
          "platform": {},
          "timezone": "Europe/Lisbon"
        }
      ],
      "preferences": [
        {
          "scope": {},
          "key": "billing",
          "channel": {},
          "enabled": false
        }
      ]
    }
  ]
}

Response

{
  "data": [
    {
      "external_id": "user_123",
      "status": {},
      "error": {}
    }
  ],
  "created": 0,
  "updated": 0,
  "failed": 0
}
get/v1/workspaces/{workspace_id}/recipientsAuth

List recipients

Newest first. Filter by `email` (case-insensitive), `auth_user_id`, or `updated_since` for an incremental sync. Pass `next_cursor` as `cursor` until `has_more` is false. `include=preferences` adds each recipient's preference cells (an export of consent).

Path parameters

workspace_id*string · uuid

Id of the workspace.

Query parameters

emailstring · email

Only the recipient(s) with this email address (case-insensitive).

auth_user_idstring · uuid

Only the recipient linked to this Auth end user.

updated_sincestring · date-time

Only recipients changed at or after this time (incremental sync).

includeenum (1)

`preferences` adds each recipient's preference cells (for an export).

cursorstring

Opaque cursor for pagination: the `next_cursor` of the previous page.

limitinteger

Page size.

Response · 200 A page of recipients.

data*array

Recipients, newest first.

pagination*object

The cursor for the next page.

Example

Request

GET https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/recipients
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "external_id": "user_123",
      "auth_user_id": "00000000-0000-0000-0000-000000000000",
      "email": "user@example.com",
      "phone": "string",
      "name": "string",
      "locale": "string",
      "timezone": "string",
      "quiet_hours": {},
      "properties": {},
      "email_invalid_at": "2026-01-01T00:00:00.000Z",
      "email_complained_at": "2026-01-01T00:00:00.000Z",
      "last_seen_at": "2026-01-01T00:00:00.000Z",
      "unsubscribed_all": false,
      "preferences": [
        {
          "scope": {},
          "key": "string",
          "channel": {},
          "enabled": false
        }
      ],
      "created_at": "2026-01-01T00:00:00.000Z",
      "updated_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "pagination": {}
}
get/v1/workspaces/{workspace_id}/recipients/{external_id}Auth

Get a recipient

The profile; devices are under `…/devices`.

Path parameters

workspace_id*string · uuid

Id of the workspace.

external_id*string

Your user id for the recipient (URL-encoded): 1-255 of `A-Z a-z 0-9 . _ : @ | -`.

Response · 200 The recipient.

id*string · uuid

Notify's id for the recipient.

external_id*string

Your user id for the recipient (the path key).

Example: "user_123"

auth_user_id*string · uuid

The linked Auth end user.

email*string · email

Email address.

phone*string

Phone number (E.164).

name*string

Display name.

locale*string

BCP 47 language tag.

timezone*string

IANA time zone.

quiet_hours*object

Quiet window.

properties*object

Your own fields, exactly as sent.

email_invalid_at*string · date-time

When the address hard-bounced; email to it stops until the address changes.

email_complained_at*string · date-time

When the recipient marked an email as spam; email to it stops until the address changes.

last_seen_at*string · date-time

Last client (recipient-token) activity.

unsubscribed_all*boolean

Opted out of every non-critical notification.

preferencesarray

Preference cells; only on `GET /recipients?include=preferences`.

created_at*string · date-time

Created.

updated_at*string · date-time

Last change.

Example

Request

GET https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/recipients/{external_id}
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "external_id": "user_123",
  "auth_user_id": "00000000-0000-0000-0000-000000000000",
  "email": "user@example.com",
  "phone": "string",
  "name": "string",
  "locale": "string",
  "timezone": "string",
  "quiet_hours": {},
  "properties": {},
  "email_invalid_at": "2026-01-01T00:00:00.000Z",
  "email_complained_at": "2026-01-01T00:00:00.000Z",
  "last_seen_at": "2026-01-01T00:00:00.000Z",
  "unsubscribed_all": false,
  "preferences": [
    {
      "scope": {},
      "key": "string",
      "channel": {},
      "enabled": false
    }
  ],
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
put/v1/workspaces/{workspace_id}/recipients/{external_id}Auth

Create or update a recipient

Creates the recipient with this external id, or updates it. Fields you leave out keep their stored value; `null` clears one; `properties` is replaced whole. Calling it again with the same body leaves the same recipient, so a retry is safe without an `Idempotency-Key` (the route does not take one).

Path parameters

workspace_id*string · uuid

Id of the workspace.

external_id*string

Your user id for the recipient (URL-encoded): 1-255 of `A-Z a-z 0-9 . _ : @ | -`.

Request body

auth_user_idstring · uuid

Link to an Auth end user (its user id) in a ProductCraft Auth app. At most one recipient per end user in a workspace. The id is not looked up: link the id your Auth app gave you.

emailstring · email

Email address for the email channel.

Example: "ada@example.com"

phonestring

Phone number in E.164 form (`+351912345678`), kept for the SMS channel planned after v1.

Example: "+351912345678"

namestring

Display name.

Example: "Ada Lovelace"

localestring

BCP 47 language tag; templates fall back from `pt-PT` to `pt` to the type default.

Example: "pt-PT"

timezonestring

IANA time zone, used for quiet hours.

Example: "Europe/Lisbon"

quiet_hoursobject

Hours when push and web push are held until the window ends (the inbox is never held).

propertiesobject

Your own fields, returned exactly as sent (keys are not re-cased). At most 4 KB as JSON. Replaced whole when present.

Example: {"plan":"pro","team_id":"team_42"}

Response · 200 Updated.

id*string · uuid

Notify's id for the recipient.

external_id*string

Your user id for the recipient (the path key).

Example: "user_123"

auth_user_id*string · uuid

The linked Auth end user.

email*string · email

Email address.

phone*string

Phone number (E.164).

name*string

Display name.

locale*string

BCP 47 language tag.

timezone*string

IANA time zone.

quiet_hours*object

Quiet window.

properties*object

Your own fields, exactly as sent.

email_invalid_at*string · date-time

When the address hard-bounced; email to it stops until the address changes.

email_complained_at*string · date-time

When the recipient marked an email as spam; email to it stops until the address changes.

last_seen_at*string · date-time

Last client (recipient-token) activity.

unsubscribed_all*boolean

Opted out of every non-critical notification.

preferencesarray

Preference cells; only on `GET /recipients?include=preferences`.

created_at*string · date-time

Created.

updated_at*string · date-time

Last change.

Example

Request

PUT https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/recipients/{external_id}
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "auth_user_id": "00000000-0000-0000-0000-000000000000",
  "email": "ada@example.com",
  "phone": "+351912345678",
  "name": "Ada Lovelace",
  "locale": "pt-PT",
  "timezone": "Europe/Lisbon",
  "quiet_hours": {},
  "properties": {
    "plan": "pro",
    "team_id": "team_42"
  }
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "external_id": "user_123",
  "auth_user_id": "00000000-0000-0000-0000-000000000000",
  "email": "user@example.com",
  "phone": "string",
  "name": "string",
  "locale": "string",
  "timezone": "string",
  "quiet_hours": {},
  "properties": {},
  "email_invalid_at": "2026-01-01T00:00:00.000Z",
  "email_complained_at": "2026-01-01T00:00:00.000Z",
  "last_seen_at": "2026-01-01T00:00:00.000Z",
  "unsubscribed_all": false,
  "preferences": [
    {
      "scope": {},
      "key": "string",
      "channel": {},
      "enabled": false
    }
  ],
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
patch/v1/workspaces/{workspace_id}/recipients/{external_id}Auth

Update a recipient

Changes the fields you send on an existing recipient (404 when there is none; use `PUT` to create). `null` clears a field.

Path parameters

workspace_id*string · uuid

Id of the workspace.

external_id*string

Your user id for the recipient (URL-encoded): 1-255 of `A-Z a-z 0-9 . _ : @ | -`.

Request body

auth_user_idstring · uuid

Link to an Auth end user (its user id) in a ProductCraft Auth app. At most one recipient per end user in a workspace. The id is not looked up: link the id your Auth app gave you.

emailstring · email

Email address for the email channel.

Example: "ada@example.com"

phonestring

Phone number in E.164 form (`+351912345678`), kept for the SMS channel planned after v1.

Example: "+351912345678"

namestring

Display name.

Example: "Ada Lovelace"

localestring

BCP 47 language tag; templates fall back from `pt-PT` to `pt` to the type default.

Example: "pt-PT"

timezonestring

IANA time zone, used for quiet hours.

Example: "Europe/Lisbon"

quiet_hoursobject

Hours when push and web push are held until the window ends (the inbox is never held).

propertiesobject

Your own fields, returned exactly as sent (keys are not re-cased). At most 4 KB as JSON. Replaced whole when present.

Example: {"plan":"pro","team_id":"team_42"}

Response · 200 Updated.

id*string · uuid

Notify's id for the recipient.

external_id*string

Your user id for the recipient (the path key).

Example: "user_123"

auth_user_id*string · uuid

The linked Auth end user.

email*string · email

Email address.

phone*string

Phone number (E.164).

name*string

Display name.

locale*string

BCP 47 language tag.

timezone*string

IANA time zone.

quiet_hours*object

Quiet window.

properties*object

Your own fields, exactly as sent.

email_invalid_at*string · date-time

When the address hard-bounced; email to it stops until the address changes.

email_complained_at*string · date-time

When the recipient marked an email as spam; email to it stops until the address changes.

last_seen_at*string · date-time

Last client (recipient-token) activity.

unsubscribed_all*boolean

Opted out of every non-critical notification.

preferencesarray

Preference cells; only on `GET /recipients?include=preferences`.

created_at*string · date-time

Created.

updated_at*string · date-time

Last change.

Example

Request

PATCH https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/recipients/{external_id}
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "auth_user_id": "00000000-0000-0000-0000-000000000000",
  "email": "ada@example.com",
  "phone": "+351912345678",
  "name": "Ada Lovelace",
  "locale": "pt-PT",
  "timezone": "Europe/Lisbon",
  "quiet_hours": {},
  "properties": {
    "plan": "pro",
    "team_id": "team_42"
  }
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "external_id": "user_123",
  "auth_user_id": "00000000-0000-0000-0000-000000000000",
  "email": "user@example.com",
  "phone": "string",
  "name": "string",
  "locale": "string",
  "timezone": "string",
  "quiet_hours": {},
  "properties": {},
  "email_invalid_at": "2026-01-01T00:00:00.000Z",
  "email_complained_at": "2026-01-01T00:00:00.000Z",
  "last_seen_at": "2026-01-01T00:00:00.000Z",
  "unsubscribed_all": false,
  "preferences": [
    {
      "scope": {},
      "key": "string",
      "channel": {},
      "enabled": false
    }
  ],
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
delete/v1/workspaces/{workspace_id}/recipients/{external_id}Auth

Erase a recipient (GDPR)

Deletes the recipient and everything Notify holds for it: devices now, and preferences, inbox items and pending deliveries as those land. Delivery history keeps ids and states but loses addresses and rendered text. The data is gone when this returns; the external id can be used again. Email already handed to ProductCraft Mail stays in Mail's message log under Mail's retention.

Path parameters

workspace_id*string · uuid

Id of the workspace.

external_id*string

Your user id for the recipient (URL-encoded): 1-255 of `A-Z a-z 0-9 . _ : @ | -`.

Response · 202 Erased.

id*string · uuid

The erased recipient id.

external_id*string

The erased external id; it can be used again for a new recipient.

Example: "user_123"

status*object

Always `erased`: the data is gone when this returns.

Example

Request

DELETE https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/recipients/{external_id}
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "external_id": "user_123",
  "status": {}
}
get/v1/workspaces/{workspace_id}/recipients/{external_id}/devicesAuth

List a recipient's devices

Most recently seen first: the active ones (at most 20) and any invalid or pruned ones not yet reaped (kept 7 days).

Path parameters

workspace_id*string · uuid

Id of the workspace.

external_id*string

Your user id for the recipient (URL-encoded): 1-255 of `A-Z a-z 0-9 . _ : @ | -`.

Response · 200 The devices.

data*array

The recipient's devices, most recently seen first: at most 20 active plus any invalid or pruned ones not yet reaped.

Example

Request

GET https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/recipients/{external_id}/devices
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "channel": {},
      "token_last6": "bb78ad",
      "app_id": "com.example.app",
      "environment": {},
      "platform": {},
      "timezone": "Europe/Lisbon",
      "state": {},
      "state_reason": "string",
      "last_seen_at": "2026-01-01T00:00:00.000Z",
      "moved_at": "2026-01-01T00:00:00.000Z",
      "created_at": "2026-01-01T00:00:00.000Z",
      "updated_at": "2026-01-01T00:00:00.000Z"
    }
  ]
}
post/v1/workspaces/{workspace_id}/recipients/{external_id}/devicesAuth

Register a device

Registers an APNs or FCM token, or a browser web push subscription, for the recipient. Registering a token the recipient already has refreshes it (200). A token registered to another recipient of the workspace moves to this one (a shared device signed in as someone else), at most 3 times per token per UTC day. A recipient keeps at most 20 active devices: registering more marks the least recently seen as `pruned` (`evicted`). The token is stored hashed and encrypted; responses show its last 6 characters only.

Path parameters

workspace_id*string · uuid

Id of the workspace.

external_id*string

Your user id for the recipient (URL-encoded): 1-255 of `A-Z a-z 0-9 . _ : @ | -`.

Headers

Idempotency-Keystring

A key (1..256 chars, [A-Za-z0-9_\-:]) makes retries safe: a replay returns the original response for 24 h.

Request body

channel*object

`apns` (Apple), `fcm` (Firebase Cloud Messaging) or `webpush` (a browser subscription).

tokenstring

The device token, for `apns` (hex) and `fcm`. Stored hashed and encrypted; responses only show its last 6 characters. Not used for `webpush`.

Example: "740f4707bebcf74f9b7c25d48e3358945f6aa01da5ddb387462c7eaf61bb78ad"

subscriptionobject

The browser push subscription, for `webpush` only.

app_id*string

The app the token belongs to: the iOS bundle id for `apns`, the Firebase project id for `fcm`, the site origin for `webpush`. Push is sent with the credential configured for this app.

Example: "com.example.app"

environmentobject

APNs only, required there: `sandbox` for development builds, `production` for TestFlight and App Store builds. A token sent to the wrong environment fails with `BadDeviceToken`.

platformobject

The operating system. Defaults to `ios` for `apns`, `android` for `fcm` and `web` for `webpush`.

timezonestring

The device's IANA time zone, used for quiet hours when the recipient has none.

Example: "Europe/Lisbon"

Response · 200 Already registered to this recipient: refreshed.

id*string · uuid

The device's id.

channel*object

The channel.

token_last6*string

The last 6 characters of the token (for `webpush`, of the endpoint). The full token is never returned.

Example: "bb78ad"

app_id*string

Bundle id, Firebase project id or site origin.

Example: "com.example.app"

environment*object

APNs environment; null for other channels.

platform*object

The operating system.

timezone*string

The device's IANA time zone.

Example: "Europe/Lisbon"

state*object

`active` receives pushes; `invalid` was rejected by the provider; `pruned` was removed (the provider reported it dead, or it was evicted as the oldest of more than 20).

state_reason*string

Why the device is not active (`evicted`, `unregistered`, `bad_device_token` …).

last_seen_at*string · date-time

Last registration or refresh.

moved_at*string · date-time

When the token last moved here from another recipient (a shared device signed in as someone else).

created_at*string · date-time

When the device was first registered.

updated_at*string · date-time

Last change.

Example

Request

POST https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/recipients/{external_id}/devices
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "channel": {},
  "token": "740f4707bebcf74f9b7c25d48e3358945f6aa01da5ddb387462c7eaf61bb78ad",
  "subscription": {},
  "app_id": "com.example.app",
  "environment": {},
  "platform": {},
  "timezone": "Europe/Lisbon"
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "channel": {},
  "token_last6": "bb78ad",
  "app_id": "com.example.app",
  "environment": {},
  "platform": {},
  "timezone": "Europe/Lisbon",
  "state": {},
  "state_reason": "string",
  "last_seen_at": "2026-01-01T00:00:00.000Z",
  "moved_at": "2026-01-01T00:00:00.000Z",
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
delete/v1/workspaces/{workspace_id}/recipients/{external_id}/devices/{device_id}Auth

Remove a device

Deletes the device (call it when the user signs out on that device). Pushes stop at once.

Path parameters

workspace_id*string · uuid

Id of the workspace.

device_id*string · uuid

Id of the device.

external_id*string

Your user id for the recipient (URL-encoded): 1-255 of `A-Z a-z 0-9 . _ : @ | -`.

Response · 204

Removed.

Example

Request

DELETE https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/recipients/{external_id}/devices/{device_id}
Authorization: Bearer YOUR_TOKEN
get/v1/workspaces/{workspace_id}/recipients/{external_id}/preferencesAuth

Get a recipient's preferences

The cells the recipient set, unsubscribe-all, time zone and quiet hours. Anything not set follows the category defaults.

Path parameters

workspace_id*string · uuid

Id of the workspace.

external_id*string

Your user id for the recipient (URL-encoded): 1-255 of `A-Z a-z 0-9 . _ : @ | -`.

Response · 200 The preferences.

external_id*string

The recipient external id.

unsubscribed_all*boolean

Opted out of every non-critical notification.

timezone*string

IANA time zone.

quiet_hours*object

Quiet window.

preferences*array

Cells the recipient set (anything else follows the category defaults).

Example

Request

GET https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/recipients/{external_id}/preferences
Authorization: Bearer YOUR_TOKEN

Response

{
  "external_id": "string",
  "unsubscribed_all": false,
  "timezone": "string",
  "quiet_hours": {},
  "preferences": [
    {
      "scope": {},
      "key": "string",
      "channel": {},
      "enabled": false
    }
  ]
}
put/v1/workspaces/{workspace_id}/recipients/{external_id}/preferencesAuth

Replace a recipient's preferences

Sets the complete set of cells: cells you leave out are removed. Most specific wins when sending: type × channel, type × `*`, category × channel, category × `*`, all × channel, then the category default. Calling it again with the same body changes nothing.

Path parameters

workspace_id*string · uuid

Id of the workspace.

external_id*string

Your user id for the recipient (URL-encoded): 1-255 of `A-Z a-z 0-9 . _ : @ | -`.

Request body

preferences*array

The recipient's complete set of cells: cells not listed are removed (back to the category defaults).

unsubscribed_allboolean

Opt out of every non-critical notification; left out keeps the current value.

Response · 200 The preferences after the change.

external_id*string

The recipient external id.

unsubscribed_all*boolean

Opted out of every non-critical notification.

timezone*string

IANA time zone.

quiet_hours*object

Quiet window.

preferences*array

Cells the recipient set (anything else follows the category defaults).

Example

Request

PUT https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/recipients/{external_id}/preferences
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "preferences": [
    {
      "scope": {},
      "key": "billing",
      "channel": {},
      "enabled": false
    }
  ],
  "unsubscribed_all": false
}

Response

{
  "external_id": "string",
  "unsubscribed_all": false,
  "timezone": "string",
  "quiet_hours": {},
  "preferences": [
    {
      "scope": {},
      "key": "string",
      "channel": {},
      "enabled": false
    }
  ]
}
patch/v1/workspaces/{workspace_id}/recipients/{external_id}/preferencesAuth

Update a recipient's preferences

Sets or removes (`enabled: null`) the cells you send and keeps the others; also sets unsubscribe-all, time zone and quiet hours.

Path parameters

workspace_id*string · uuid

Id of the workspace.

external_id*string

Your user id for the recipient (URL-encoded): 1-255 of `A-Z a-z 0-9 . _ : @ | -`.

Request body

preferencesarray

Cells to set or (with `enabled: null`) remove; other cells are kept.

unsubscribed_allboolean

Opt out of (or back into) every non-critical notification.

timezonestring

IANA time zone of the recipient (quiet hours).

Example: "Europe/Lisbon"

quiet_hoursobject

Quiet window; null removes it.

Response · 200 The preferences after the change.

external_id*string

The recipient external id.

unsubscribed_all*boolean

Opted out of every non-critical notification.

timezone*string

IANA time zone.

quiet_hours*object

Quiet window.

preferences*array

Cells the recipient set (anything else follows the category defaults).

Example

Request

PATCH https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/recipients/{external_id}/preferences
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "preferences": [
    {
      "scope": {},
      "key": "billing",
      "channel": {},
      "enabled": false
    }
  ],
  "unsubscribed_all": false,
  "timezone": "Europe/Lisbon",
  "quiet_hours": {}
}

Response

{
  "external_id": "string",
  "unsubscribed_all": false,
  "timezone": "string",
  "quiet_hours": {},
  "preferences": [
    {
      "scope": {},
      "key": "string",
      "channel": {},
      "enabled": false
    }
  ]
}
get/v1/workspaces/{workspace_id}/recipients/{external_id}/inboxAuth

List a recipient's inbox items

What the recipient sees in their in-app inbox, newest first, with their unseen and unread counts. `status` picks active (default), archived or all items. Read-only: recipients mark items through the client API.

Path parameters

workspace_id*string · uuid

Id of the workspace.

external_id*string

Your user id for the recipient (URL-encoded): 1-255 of `A-Z a-z 0-9 . _ : @ | -`.

Query parameters

statusenum (3)

`active` (not archived), `archived` or `all`.

tenantstring

Only this tenant.

cursorstring

Opaque cursor for pagination: the `next_cursor` of the previous page.

limitinteger

Page size.

Response · 200 A page of inbox items.

data*array

Items, newest first.

pagination*object

The cursor for the next page.

counts*object

The recipient's unseen and unread counts.

Example

Request

GET https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/recipients/{external_id}/inbox
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "notification_id": "00000000-0000-0000-0000-000000000000",
      "type": "string",
      "category": "string",
      "tenant": "string",
      "title": "string",
      "body": "string",
      "action_url": "string",
      "avatar_url": "string",
      "actions": [
        {
          "label": "string",
          "url": "string",
          "action_key": "string"
        }
      ],
      "actor": {},
      "data": {},
      "seen_at": "2026-01-01T00:00:00.000Z",
      "read_at": "2026-01-01T00:00:00.000Z",
      "archived_at": "2026-01-01T00:00:00.000Z",
      "interacted_at": "2026-01-01T00:00:00.000Z",
      "interaction_key": "string",
      "retracted_at": "2026-01-01T00:00:00.000Z",
      "created_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "pagination": {},
  "counts": {}
}
get/v1/workspaces/{workspace_id}/recipients/{external_id}/timelineAuth

Get a recipient's delivery timeline

Every delivery to this recipient across notifications, newest first, with the reason for each skip or failure, the device's state and its last successful delivery; the first page also lists device token moves in and out of the recipient.

Path parameters

workspace_id*string · uuid

Id of the workspace.

external_id*string

Your user id for the recipient (URL-encoded): 1-255 of `A-Z a-z 0-9 . _ : @ | -`.

Query parameters

cursorstring

Opaque cursor for pagination: the `next_cursor` of the previous page.

limitinteger

Page size.

Response · 200 A page of the timeline.

data*array

Every delivery to the recipient, newest first.

pagination*object

The cursor for the next page.

device_moves*array

Device token moves in or out of this recipient (latest first, at most 50).

Example

Request

GET https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/recipients/{external_id}/timeline
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "notification_id": "00000000-0000-0000-0000-000000000000",
      "recipient_id": "00000000-0000-0000-0000-000000000000",
      "recipient_external_id": "string",
      "channel": {},
      "provider": "string",
      "device_id": "00000000-0000-0000-0000-000000000000",
      "target": "string",
      "fallback_step": 0,
      "is_fallback": false,
      "state": {},
      "reason": {},
      "explanation": "string",
      "attempts": 0,
      "next_attempt_at": "2026-01-01T00:00:00.000Z",
      "provider_message_id": "string",
      "provider_unique_id": "string",
      "rendered": {},
      "warnings": [
        "string"
      ],
      "sent_at": "2026-01-01T00:00:00.000Z",
      "final_at": "2026-01-01T00:00:00.000Z",
      "created_at": "2026-01-01T00:00:00.000Z",
      "type": "string",
      "device": {}
    }
  ],
  "pagination": {},
  "device_moves": [
    {
      "device_id": "00000000-0000-0000-0000-000000000000",
      "channel": "string",
      "direction": {},
      "moved_at": "2026-01-01T00:00:00.000Z"
    }
  ]
}

Settings

Workspace settings: email on or off, and the automatic email pause (more than 0.1 % complaints or 5 % hard bounces among recent emails) with how to resume it.

get/v1/workspaces/{workspace_id}/settingsAuth

Get Notify settings

Email on/off and, after an automatic pause, when and why email was paused; the default time zone and quiet hours for recipients without their own.

Path parameters

workspace_id*string · uuid

Id of the workspace.

Response · 200 The settings.

email_enabled*boolean

Whether Notify sends email for this workspace. Off: every email delivery is skipped as `channel_not_configured`.

email_paused_at*string · date-time

Set when Notify paused email automatically: more than 0.1 % complaints or 5 % hard bounces among the last 500 email deliveries (counted once at least 100 exist). While set, email deliveries are skipped as `email_paused`, critical types included. Clear it with `PATCH` `{ "email_paused": false }`.

email_paused_reason*string

Why email was paused, e.g. "2 complaints in the last 140 emails".

email_resumed_at*string · date-time

When email was last resumed after a pause. The pause check only counts emails sent after it.

default_timezone*string

IANA time zone for recipients who have none on their profile (and no time zone on the device, for push). Null: recipients without a time zone are never held for quiet hours.

Example: "Europe/Lisbon"

default_quiet_hours*object

Quiet hours for recipients who set none of their own. Push and web push wait until the window ends (plus up to 10 minutes, so a morning backlog spreads out); email waits only for types with `email_quiet_hours`; the inbox and critical types never wait. A hold past the type TTL fails as `expired`.

client_origins*array

Browser origins allowed to call the client API (`/v1/client/…`) with a recipient token. Empty: only `http://localhost` (any port) is accepted, for development. Calls without an `Origin` header (native apps) are not origin-checked.

Example: ["https://app.example.com"]

Example

Request

GET https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/settings
Authorization: Bearer YOUR_TOKEN

Response

{
  "email_enabled": false,
  "email_paused_at": "2026-01-01T00:00:00.000Z",
  "email_paused_reason": "string",
  "email_resumed_at": "2026-01-01T00:00:00.000Z",
  "default_timezone": "Europe/Lisbon",
  "default_quiet_hours": {},
  "client_origins": [
    "https://app.example.com"
  ]
}
patch/v1/workspaces/{workspace_id}/settingsAuth

Update Notify settings

Turn email on or off, set the default time zone and quiet hours (`null` clears either), replace the browser origins allowed on the client API (`client_origins`), or resume email after an automatic pause with `{ "email_paused": false }`. Resuming starts a fresh pause window: only emails sent after it count. Fix the cause first (where the addresses come from, whether people expect the email), or the next complaints pause it again. Calling it again with the same body changes nothing.

Path parameters

workspace_id*string · uuid

Id of the workspace.

Request body

email_enabledboolean

Turn Notify email on or off for the workspace.

email_pausedenum (1)

Send `false` to resume email after an automatic pause. Pausing by hand is not supported: turn `email_enabled` off instead.

default_timezonestring

IANA time zone used for recipients without one. `null` clears it.

Example: "Europe/Lisbon"

default_quiet_hoursobject

Quiet hours for recipients who set none, e.g. `{ "start": "22:00", "end": "08:00" }` (an end before the start spans midnight). `null` clears it.

client_originsarray

Replaces the browser origins allowed to call the client API: `https://host[:port]` (or `http://localhost[:port]`), no path. `[]` goes back to localhost only.

Example: ["https://app.example.com","https://www.example.com"]

Response · 200 The settings after the change.

email_enabled*boolean

Whether Notify sends email for this workspace. Off: every email delivery is skipped as `channel_not_configured`.

email_paused_at*string · date-time

Set when Notify paused email automatically: more than 0.1 % complaints or 5 % hard bounces among the last 500 email deliveries (counted once at least 100 exist). While set, email deliveries are skipped as `email_paused`, critical types included. Clear it with `PATCH` `{ "email_paused": false }`.

email_paused_reason*string

Why email was paused, e.g. "2 complaints in the last 140 emails".

email_resumed_at*string · date-time

When email was last resumed after a pause. The pause check only counts emails sent after it.

default_timezone*string

IANA time zone for recipients who have none on their profile (and no time zone on the device, for push). Null: recipients without a time zone are never held for quiet hours.

Example: "Europe/Lisbon"

default_quiet_hours*object

Quiet hours for recipients who set none of their own. Push and web push wait until the window ends (plus up to 10 minutes, so a morning backlog spreads out); email waits only for types with `email_quiet_hours`; the inbox and critical types never wait. A hold past the type TTL fails as `expired`.

client_origins*array

Browser origins allowed to call the client API (`/v1/client/…`) with a recipient token. Empty: only `http://localhost` (any port) is accepted, for development. Calls without an `Origin` header (native apps) are not origin-checked.

Example: ["https://app.example.com"]

Example

Request

PATCH https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/settings
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "email_enabled": false,
  "email_paused": false,
  "default_timezone": "Europe/Lisbon",
  "default_quiet_hours": {},
  "client_origins": [
    "https://app.example.com",
    "https://www.example.com"
  ]
}

Response

{
  "email_enabled": false,
  "email_paused_at": "2026-01-01T00:00:00.000Z",
  "email_paused_reason": "string",
  "email_resumed_at": "2026-01-01T00:00:00.000Z",
  "default_timezone": "Europe/Lisbon",
  "default_quiet_hours": {},
  "client_origins": [
    "https://app.example.com"
  ]
}

Setup-Status

A checklist of what Notify needs before notifications reach anyone (a notification type, the Mail sender and templates for email), with where to fix each item.

get/v1/workspaces/{workspace_id}/setup-statusAuth

Get the setup checklist for Notify: what works, what is missing, and where to fix it.

Rows, in order: `notification_type` (not_set until an active type exists), `email` (attention while email is off, blocked while Notify has paused it), and, once a type uses email, `mail_sender` (the Mail workspace default sender: pass when its domain is verified) and `email_templates` (every Mail template name your types use exists in Mail). Notify sends email through ProductCraft Mail and holds no credential for it: Mail is read with your own access, so a key without Mail read access shows those rows as `attention`. `complete` is true once no row is `not_set` or `blocked`. Each `fix.href` is a console path. Computed on every request.

Path parameters

workspace_id*string · uuid

Id of the workspace.

Response · 200 The checklist, computed now.

checked_at*string · date-time

When the server computed these rows.

Example: "2026-10-16T12:00:00.000Z"

complete*boolean

True when no row is `not_set` or `blocked`. `attention` rows do not stop setup from being complete.

Example: false

rows*array

Checklist rows in the order they should be shown.

Example

Request

GET https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/setup-status
Authorization: Bearer YOUR_TOKEN

Response

{
  "checked_at": "2026-10-16T12:00:00.000Z",
  "complete": false,
  "rows": [
    {
      "id": "redirect_origins",
      "title": "Sites allowed to start sign-in",
      "state": "blocked",
      "detail": "No site is allowed to start sign-in, so web sign-in is refused. Add the origin of your site, for example https://app.example.com.",
      "fix": {},
      "test": {}
    }
  ]
}

Stats

Daily delivery counts per channel from an hourly rollup (planned, sent, delivered, failed, skipped, inbox reads), kept beyond the 30 days of delivery history.

get/v1/workspaces/{workspace_id}/statsAuth

Get Notify stats

Daily delivery counts per channel for a range of UTC days (default the last 30, at most 90): planned, sent, delivered, failed, skipped, and inbox items read. Counted by the day a delivery was planned, from an hourly rollup: today and yesterday are refreshed every hour, so the newest counts can be up to an hour old (`updated_at`). Kept beyond the 30 days that delivery history is kept.

Path parameters

workspace_id*string · uuid

Id of the workspace.

Query parameters

fromstring · date

First UTC day (inclusive). Default: 29 days before `to`.

tostring · date

Last UTC day (inclusive). Default: today. At most 90 days after `from`.

Response · 200 The stats.

from*string · date

First day of the range.

Example: "2026-11-01"

to*string · date

Last day of the range.

Example: "2026-11-10"

data*array

One row per day and channel that had deliveries, oldest first. Days without deliveries are absent.

totals*object

The range summed over every day and channel.

updated_at*string · date-time

When the rollup last wrote this range (it runs hourly; today and yesterday are refreshed each time). Null when nothing was rolled up yet.

Example: "2026-11-10T09:05:00.000Z"

Example

Request

GET https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/stats
Authorization: Bearer YOUR_TOKEN

Response

{
  "from": "2026-11-01",
  "to": "2026-11-10",
  "data": [
    {
      "accepted": 120,
      "sent": 96,
      "delivered": 40,
      "failed": 2,
      "skipped": 22,
      "read": 18,
      "day": "2026-11-10",
      "channel": "push"
    }
  ],
  "totals": {},
  "updated_at": "2026-11-10T09:05:00.000Z"
}

Webhooks

Signed webhooks (Standard Webhooks) for sends, delivery outcomes (sent, delivered, failed, bounced, skipped with the reason), inbox reads, archives and clicks, pruned and moved devices, refused credentials, the email auto-pause and recipient erasure, with an event log, retries, Send test and replay. Payloads carry ids, the type key, channel, state and reason: never rendered text, addresses or tokens.

get/v1/workspaces/{workspace_id}/webhooks/event-typesAuth

List the events an endpoint can subscribe to.

Each type with a plain description, its group and an example `data` object (what "Send test" delivers).

Path parameters

workspace_id*string · uuid

Id of the workspace.

Response · 200

data*array

Every event type this product sends.

Example

Request

GET https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/webhooks/event-types
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "type": "notification.accepted",
      "description": "A user signed up or was created by an admin.",
      "category": "Users",
      "example": {}
    }
  ]
}
get/v1/workspaces/{workspace_id}/webhooks/endpointsAuth

List webhook endpoints, oldest first.

At most 10, so this is always one page. Signing secrets are never returned here, only their last 4 characters.

Path parameters

workspace_id*string · uuid

Id of the workspace.

Response · 200

data*array

The endpoints, newest first.

pagination*object

Always one page (an endpoint limit applies).

Example

Request

GET https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/webhooks/endpoints
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "scope_type": "workspace",
      "scope_id": "string",
      "url": "https://api.acme.com/webhooks",
      "description": "Production receiver",
      "event_types": [],
      "enabled": false,
      "status": {},
      "disabled_at": "2026-01-01T00:00:00.000Z",
      "disabled_reason": {},
      "consecutive_failures": 0,
      "first_failure_at": "2026-01-01T00:00:00.000Z",
      "signing_secret_hint": "x9Q=",
      "prev_signing_secret_active": false,
      "prev_signing_secret_expires_at": "2026-01-01T00:00:00.000Z",
      "legacy_signature": false,
      "created_at": "2026-01-01T00:00:00.000Z",
      "updated_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "pagination": {}
}
post/v1/workspaces/{workspace_id}/webhooks/endpointsAuth

Add a webhook endpoint. Returns its signing secret once.

Deliveries are signed with the Standard Webhooks headers `webhook-id`, `webhook-timestamp` and `webhook-signature` (`v1,<base64 HMAC-SHA256 of "<id>.<timestamp>.<body>">`); the returned `whsec_…` secret works unchanged with any Standard Webhooks library. Failed deliveries are retried after 1 min, 5 min, 30 min, 2 h, 6 h and 24 h.

Path parameters

workspace_id*string · uuid

Id of the workspace.

Headers

Idempotency-Keystring

Opaque client-generated string (1-256 chars, `[A-Za-z0-9_-:]`) that makes a retry of this request safe for 24 hours. The same key with a different body returns 409 `IDEMPOTENCY_KEY_REUSE`.

Request body

url*string · uri

Where to send events: an absolute https:// URL on a public host.

Example: "https://api.acme.com/webhooks"

descriptionstring

A label for people.

Example: "Production receiver"

event_typesarray

Event types from `GET …/webhooks/event-types`. Empty or omitted means every event, including types added later.

Example: ["notification.accepted","delivery.sent"]

enabledboolean

Send `false` to create it switched off.

Example: true

Response · 201

id*string · uuid

The endpoint's id.

scope_type*string

What owns the endpoint: `workspace`, `app` or `community`.

Example: "workspace"

scope_id*string

Id of the owning workspace, app or community.

url*string · uri

Where events are sent.

Example: "https://api.acme.com/webhooks"

description*string

A label for people; null when unset.

Example: "Production receiver"

event_types*array

Subscribed event types. Empty means every event, including types added later.

Example: []

enabled*boolean

The on/off switch (`PATCH … { enabled }`).

status*object

`enabled` receives events. `disabled` was switched off. `auto_disabled` was switched off after 20 failed deliveries in a row or 24 hours of failures; `PATCH { enabled: true }` turns it back on.

disabled_at*string · date-time

When it was switched off; null while enabled.

disabled_reason*object

`manual`: switched off by a caller. `failing`: switched off automatically after failed deliveries. Null while enabled.

consecutive_failures*number

Failed automatic deliveries in a row; a success resets it.

first_failure_at*string · date-time

Start of the current failure streak.

signing_secret_hint*string

Last 4 characters of the signing secret.

Example: "x9Q="

prev_signing_secret_active*boolean

True while the previous secret still signs too (after a rotation, for its grace period).

prev_signing_secret_expires_at*string · date-time

Until then the previous secret signs too (after a rotation).

legacy_signature*boolean

True for endpoints carried over from the product's previous webhook system: they also receive the old signature header until its sunset date.

created_at*string · date-time

When the endpoint was created.

updated_at*string · date-time

When the endpoint last changed.

signing_secret*string

The signing secret, shown once: pass it unchanged to any Standard Webhooks verifier. Store it now; it cannot be shown again.

Example: "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"

Example

Request

POST https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/webhooks/endpoints
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "url": "https://api.acme.com/webhooks",
  "description": "Production receiver",
  "event_types": [
    "notification.accepted",
    "delivery.sent"
  ],
  "enabled": true
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "scope_type": "workspace",
  "scope_id": "string",
  "url": "https://api.acme.com/webhooks",
  "description": "Production receiver",
  "event_types": [],
  "enabled": false,
  "status": {},
  "disabled_at": "2026-01-01T00:00:00.000Z",
  "disabled_reason": {},
  "consecutive_failures": 0,
  "first_failure_at": "2026-01-01T00:00:00.000Z",
  "signing_secret_hint": "x9Q=",
  "prev_signing_secret_active": false,
  "prev_signing_secret_expires_at": "2026-01-01T00:00:00.000Z",
  "legacy_signature": false,
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z",
  "signing_secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
}
get/v1/workspaces/{workspace_id}/webhooks/endpoints/{endpoint_id}Auth

Get one webhook endpoint (without its secret).

The endpoint's URL, subscription, on/off state, failure streak and the last 4 characters of its signing secret. The full secret is only returned when the endpoint is created or its secret is rotated.

Path parameters

workspace_id*string · uuid

Id of the workspace.

endpoint_id*string · uuid

Id of the endpoint.

Response · 200

id*string · uuid

The endpoint's id.

scope_type*string

What owns the endpoint: `workspace`, `app` or `community`.

Example: "workspace"

scope_id*string

Id of the owning workspace, app or community.

url*string · uri

Where events are sent.

Example: "https://api.acme.com/webhooks"

description*string

A label for people; null when unset.

Example: "Production receiver"

event_types*array

Subscribed event types. Empty means every event, including types added later.

Example: []

enabled*boolean

The on/off switch (`PATCH … { enabled }`).

status*object

`enabled` receives events. `disabled` was switched off. `auto_disabled` was switched off after 20 failed deliveries in a row or 24 hours of failures; `PATCH { enabled: true }` turns it back on.

disabled_at*string · date-time

When it was switched off; null while enabled.

disabled_reason*object

`manual`: switched off by a caller. `failing`: switched off automatically after failed deliveries. Null while enabled.

consecutive_failures*number

Failed automatic deliveries in a row; a success resets it.

first_failure_at*string · date-time

Start of the current failure streak.

signing_secret_hint*string

Last 4 characters of the signing secret.

Example: "x9Q="

prev_signing_secret_active*boolean

True while the previous secret still signs too (after a rotation, for its grace period).

prev_signing_secret_expires_at*string · date-time

Until then the previous secret signs too (after a rotation).

legacy_signature*boolean

True for endpoints carried over from the product's previous webhook system: they also receive the old signature header until its sunset date.

created_at*string · date-time

When the endpoint was created.

updated_at*string · date-time

When the endpoint last changed.

Example

Request

GET https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/webhooks/endpoints/{endpoint_id}
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "scope_type": "workspace",
  "scope_id": "string",
  "url": "https://api.acme.com/webhooks",
  "description": "Production receiver",
  "event_types": [],
  "enabled": false,
  "status": {},
  "disabled_at": "2026-01-01T00:00:00.000Z",
  "disabled_reason": {},
  "consecutive_failures": 0,
  "first_failure_at": "2026-01-01T00:00:00.000Z",
  "signing_secret_hint": "x9Q=",
  "prev_signing_secret_active": false,
  "prev_signing_secret_expires_at": "2026-01-01T00:00:00.000Z",
  "legacy_signature": false,
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
patch/v1/workspaces/{workspace_id}/webhooks/endpoints/{endpoint_id}Auth

Change a webhook endpoint: URL, description, events, on/off.

Only the fields sent change. `enabled: true` (or a new URL) also clears an automatic disable. Switching off cancels queued retries. The secret is unchanged; rotate it with `POST …/rotate-secret`.

Path parameters

workspace_id*string · uuid

Id of the workspace.

endpoint_id*string · uuid

Id of the endpoint.

Headers

Idempotency-Keystring

Opaque client-generated string (1-256 chars, `[A-Za-z0-9_-:]`) that makes a retry of this request safe for 24 hours. The same key with a different body returns 409 `IDEMPOTENCY_KEY_REUSE`.

Request body

urlstring · uri

A new absolute https:// URL on a public host.

Example: "https://api.acme.com/webhooks"

descriptionstring

A new label; null clears it.

Example: "Production receiver"

event_typesarray

Replaces the subscription. Event types from `GET …/webhooks/event-types`. Empty or omitted means every event, including types added later.

Example: ["notification.accepted","delivery.sent"]

enabledboolean

Switch it on or off. Switching on also clears an automatic disable.

Example: true

Response · 200

id*string · uuid

The endpoint's id.

scope_type*string

What owns the endpoint: `workspace`, `app` or `community`.

Example: "workspace"

scope_id*string

Id of the owning workspace, app or community.

url*string · uri

Where events are sent.

Example: "https://api.acme.com/webhooks"

description*string

A label for people; null when unset.

Example: "Production receiver"

event_types*array

Subscribed event types. Empty means every event, including types added later.

Example: []

enabled*boolean

The on/off switch (`PATCH … { enabled }`).

status*object

`enabled` receives events. `disabled` was switched off. `auto_disabled` was switched off after 20 failed deliveries in a row or 24 hours of failures; `PATCH { enabled: true }` turns it back on.

disabled_at*string · date-time

When it was switched off; null while enabled.

disabled_reason*object

`manual`: switched off by a caller. `failing`: switched off automatically after failed deliveries. Null while enabled.

consecutive_failures*number

Failed automatic deliveries in a row; a success resets it.

first_failure_at*string · date-time

Start of the current failure streak.

signing_secret_hint*string

Last 4 characters of the signing secret.

Example: "x9Q="

prev_signing_secret_active*boolean

True while the previous secret still signs too (after a rotation, for its grace period).

prev_signing_secret_expires_at*string · date-time

Until then the previous secret signs too (after a rotation).

legacy_signature*boolean

True for endpoints carried over from the product's previous webhook system: they also receive the old signature header until its sunset date.

created_at*string · date-time

When the endpoint was created.

updated_at*string · date-time

When the endpoint last changed.

Example

Request

PATCH https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/webhooks/endpoints/{endpoint_id}
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "url": "https://api.acme.com/webhooks",
  "description": "Production receiver",
  "event_types": [
    "notification.accepted",
    "delivery.sent"
  ],
  "enabled": true
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "scope_type": "workspace",
  "scope_id": "string",
  "url": "https://api.acme.com/webhooks",
  "description": "Production receiver",
  "event_types": [],
  "enabled": false,
  "status": {},
  "disabled_at": "2026-01-01T00:00:00.000Z",
  "disabled_reason": {},
  "consecutive_failures": 0,
  "first_failure_at": "2026-01-01T00:00:00.000Z",
  "signing_secret_hint": "x9Q=",
  "prev_signing_secret_active": false,
  "prev_signing_secret_expires_at": "2026-01-01T00:00:00.000Z",
  "legacy_signature": false,
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
delete/v1/workspaces/{workspace_id}/webhooks/endpoints/{endpoint_id}Auth

Delete a webhook endpoint.

Queued retries are dropped. Its past attempts stay in the event log until the events expire.

Path parameters

workspace_id*string · uuid

Id of the workspace.

endpoint_id*string · uuid

Id of the endpoint.

Response · 204

Deleted.

Example

Request

DELETE https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/webhooks/endpoints/{endpoint_id}
Authorization: Bearer YOUR_TOKEN
post/v1/workspaces/{workspace_id}/webhooks/endpoints/{endpoint_id}/rotate-secretAuth

Rotate the signing secret. The new one is returned once.

For `grace_hours` (default 24) the previous secret keeps signing too: `webhook-signature` then lists one `v1,` signature per secret, so receivers can switch over without dropping a delivery.

Path parameters

workspace_id*string · uuid

Id of the workspace.

endpoint_id*string · uuid

Id of the endpoint.

Headers

Idempotency-Keystring

Opaque client-generated string (1-256 chars, `[A-Za-z0-9_-:]`) that makes a retry of this request safe for 24 hours. The same key with a different body returns 409 `IDEMPOTENCY_KEY_REUSE`.

Request body

grace_hoursnumber

Hours the previous secret keeps signing too (0 ends it now).

Example: 24

Response · 200

id*string · uuid

The endpoint's id.

scope_type*string

What owns the endpoint: `workspace`, `app` or `community`.

Example: "workspace"

scope_id*string

Id of the owning workspace, app or community.

url*string · uri

Where events are sent.

Example: "https://api.acme.com/webhooks"

description*string

A label for people; null when unset.

Example: "Production receiver"

event_types*array

Subscribed event types. Empty means every event, including types added later.

Example: []

enabled*boolean

The on/off switch (`PATCH … { enabled }`).

status*object

`enabled` receives events. `disabled` was switched off. `auto_disabled` was switched off after 20 failed deliveries in a row or 24 hours of failures; `PATCH { enabled: true }` turns it back on.

disabled_at*string · date-time

When it was switched off; null while enabled.

disabled_reason*object

`manual`: switched off by a caller. `failing`: switched off automatically after failed deliveries. Null while enabled.

consecutive_failures*number

Failed automatic deliveries in a row; a success resets it.

first_failure_at*string · date-time

Start of the current failure streak.

signing_secret_hint*string

Last 4 characters of the signing secret.

Example: "x9Q="

prev_signing_secret_active*boolean

True while the previous secret still signs too (after a rotation, for its grace period).

prev_signing_secret_expires_at*string · date-time

Until then the previous secret signs too (after a rotation).

legacy_signature*boolean

True for endpoints carried over from the product's previous webhook system: they also receive the old signature header until its sunset date.

created_at*string · date-time

When the endpoint was created.

updated_at*string · date-time

When the endpoint last changed.

signing_secret*string

The signing secret, shown once: pass it unchanged to any Standard Webhooks verifier. Store it now; it cannot be shown again.

Example: "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"

Example

Request

POST https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/webhooks/endpoints/{endpoint_id}/rotate-secret
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "grace_hours": 24
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "scope_type": "workspace",
  "scope_id": "string",
  "url": "https://api.acme.com/webhooks",
  "description": "Production receiver",
  "event_types": [],
  "enabled": false,
  "status": {},
  "disabled_at": "2026-01-01T00:00:00.000Z",
  "disabled_reason": {},
  "consecutive_failures": 0,
  "first_failure_at": "2026-01-01T00:00:00.000Z",
  "signing_secret_hint": "x9Q=",
  "prev_signing_secret_active": false,
  "prev_signing_secret_expires_at": "2026-01-01T00:00:00.000Z",
  "legacy_signature": false,
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z",
  "signing_secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
}
post/v1/workspaces/{workspace_id}/webhooks/endpoints/{endpoint_id}/testAuth

Send a test event to this endpoint now and return the result.

Delivers the catalogue example of `event_type`, signed like any delivery, with `"test": true` in the body. Works while the endpoint is switched off or not subscribed to that type, is logged in the event log, and is never retried or counted toward an automatic disable. Waits up to 15 s for your receiver.

Path parameters

workspace_id*string · uuid

Id of the workspace.

endpoint_id*string · uuid

Id of the endpoint.

Headers

Idempotency-Keystring

Opaque client-generated string (1-256 chars, `[A-Za-z0-9_-:]`) that makes a retry of this request safe for 24 hours. The same key with a different body returns 409 `IDEMPOTENCY_KEY_REUSE`.

Request body

event_type*enum (14)

Which event to send; its catalogue example is the `data`.

Example: "notification.accepted"

Response · 200 The attempt, successful or not.

id*string · uuid

The attempt's id.

event_id*string

The event that was delivered.

Example: "evt_3f0c3c1e-8a2b-4c55-9d3f-0a1b2c3d4e5f"

endpoint_id*string · uuid

The endpoint it was delivered to.

event_type*string

The event's type.

Example: "user.created"

attempt_number*number

1 for the first delivery; retries and replays count up.

trigger*object

`automatic` (first delivery or a retry), `replay` or `test`.

url*string · uri

Where it was sent.

status_code*number

HTTP status; null when no response came back.

succeeded*boolean

True for a 2xx response.

error_message*string

Why the attempt failed when no 2xx came back; null on success.

Example: "request timed out"

response_body*string

The first 1 KiB of the response body.

latency_ms*number

Milliseconds until the response; null when none came back.

attempted_at*string · date-time

When the attempt was made.

Example

Request

POST https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/webhooks/endpoints/{endpoint_id}/test
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "event_type": "notification.accepted"
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "event_id": "evt_3f0c3c1e-8a2b-4c55-9d3f-0a1b2c3d4e5f",
  "endpoint_id": "00000000-0000-0000-0000-000000000000",
  "event_type": "user.created",
  "attempt_number": 0,
  "trigger": {},
  "url": "string",
  "status_code": 0,
  "succeeded": false,
  "error_message": "request timed out",
  "response_body": "string",
  "latency_ms": 0,
  "attempted_at": "2026-01-01T00:00:00.000Z"
}
get/v1/workspaces/{workspace_id}/webhooks/eventsAuth

List webhook events, newest first (kept for 30 days).

Every event logged while at least one endpoint existed, with the latest state per endpoint and when the next retry is due.

Path parameters

workspace_id*string · uuid

Id of the workspace.

Query parameters

typearray

Only these types. Repeat the parameter or comma-separate.

endpoint_idstring · uuid

Only events sent (or queued) to this endpoint.

statusenum (4)

Only events in this delivery status.

created_afterstring · date-time

Only events created at or after this instant.

created_beforestring · date-time

Only events created before this instant.

limitinteger

Page size.

cursorstring

The previous page's `pagination.next_cursor`.

Response · 200

data*array

The events, newest first.

pagination*object

The cursor for the next page.

Example

Request

GET https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/webhooks/events
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "evt_3f0c3c1e-8a2b-4c55-9d3f-0a1b2c3d4e5f",
      "type": "notification.accepted",
      "created_at": "2026-01-01T00:00:00.000Z",
      "test": false,
      "status": {},
      "payload": {},
      "deliveries": [
        {
          "endpoint_id": "00000000-0000-0000-0000-000000000000",
          "attempt_number": 0,
          "status_code": 0,
          "succeeded": false,
          "attempted_at": "2026-01-01T00:00:00.000Z",
          "next_attempt_at": "2026-01-01T00:00:00.000Z"
        }
      ]
    }
  ],
  "pagination": {}
}
get/v1/workspaces/{workspace_id}/webhooks/events/{event_id}Auth

Get one webhook event with every delivery attempt.

The event as it was sent (`type`, `data`) and every delivery attempt to every endpoint, with its trigger, HTTP status, error and the start of the response body. Use it to debug a receiver, then `replay` to send it again.

Path parameters

workspace_id*string · uuid

Id of the workspace.

event_id*string

The event id, `evt_<uuid>`.

Response · 200

id*string

Sent as `webhook-id`; the same on every retry and replay.

Example: "evt_3f0c3c1e-8a2b-4c55-9d3f-0a1b2c3d4e5f"

type*object

The event type (one of this product's catalogue).

Example: "notification.accepted"

created_at*string · date-time

When the event happened.

test*boolean

Made by "Send test".

status*object

`pending`: a delivery is queued. `failed`: the latest attempt to some endpoint failed and none is queued. `delivered`: every endpoint answered 2xx. `not_sent`: no endpoint subscribed.

payload*object

The JSON body exactly as sent.

deliveries*array

Latest attempt per endpoint (an endpoint whose first attempt is still queued is not listed yet).

attempts*array

Every attempt, newest first.

Example

Request

GET https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/webhooks/events/{event_id}
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "evt_3f0c3c1e-8a2b-4c55-9d3f-0a1b2c3d4e5f",
  "type": "notification.accepted",
  "created_at": "2026-01-01T00:00:00.000Z",
  "test": false,
  "status": {},
  "payload": {},
  "deliveries": [
    {
      "endpoint_id": "00000000-0000-0000-0000-000000000000",
      "attempt_number": 0,
      "status_code": 0,
      "succeeded": false,
      "attempted_at": "2026-01-01T00:00:00.000Z",
      "next_attempt_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "attempts": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "event_id": "evt_3f0c3c1e-8a2b-4c55-9d3f-0a1b2c3d4e5f",
      "endpoint_id": "00000000-0000-0000-0000-000000000000",
      "event_type": "user.created",
      "attempt_number": 0,
      "trigger": {},
      "url": "string",
      "status_code": 0,
      "succeeded": false,
      "error_message": "request timed out",
      "response_body": "string",
      "latency_ms": 0,
      "attempted_at": "2026-01-01T00:00:00.000Z"
    }
  ]
}
post/v1/workspaces/{workspace_id}/webhooks/events/{event_id}/replayAuth

Send a logged event again, to its endpoints or to one endpoint.

Same body and `webhook-id`, freshly signed, to every switched-on endpoint that receives this event type now, or only to `endpoint_id`. Waits for the receivers (up to 15 s each) and returns the attempts. A success cancels a retry still queued for that endpoint.

Path parameters

workspace_id*string · uuid

Id of the workspace.

event_id*string

The event id, `evt_<uuid>`.

Query parameters

endpoint_idstring · uuid

Send to this endpoint only.

Headers

Idempotency-Keystring

Opaque client-generated string (1-256 chars, `[A-Za-z0-9_-:]`) that makes a retry of this request safe for 24 hours. The same key with a different body returns 409 `IDEMPOTENCY_KEY_REUSE`.

Response · 200

event_id*string

The replayed event (the same id as the original).

attempts*array

One attempt per endpoint it was sent to.

Example

Request

POST https://api.notify.productcraft.co/v1/workspaces/{workspace_id}/webhooks/events/{event_id}/replay
Authorization: Bearer YOUR_TOKEN

Response

{
  "event_id": "string",
  "attempts": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "event_id": "evt_3f0c3c1e-8a2b-4c55-9d3f-0a1b2c3d4e5f",
      "endpoint_id": "00000000-0000-0000-0000-000000000000",
      "event_type": "user.created",
      "attempt_number": 0,
      "trigger": {},
      "url": "string",
      "status_code": 0,
      "succeeded": false,
      "error_message": "request timed out",
      "response_body": "string",
      "latency_ms": 0,
      "attempted_at": "2026-01-01T00:00:00.000Z"
    }
  ]
}