Push Notifications
How alerts and other updates reach a User: on their devices through Apple Push Notification service (APNs), Firebase Cloud Messaging (FCM), and Web Push, and in an inbox their client reads through this API. See Domain Model → Notifications for the entities, and Webhooks for the separate, server-to-server channel that tells an Application about changes.
- How it fits together
- Endpoints
- Push credentials
- Devices
- Sending a notification
- The inbox
- Platform alerts
- What a device receives
- Delivery
- Sessions and device lifecycle
- Test mode
- Webhook events
- Errors specific to this resource
Implementation status. Specified 2026-10-17 (Changelog). Until the reference implementation builds it, every operation on this page returns 501 not_implemented. Its defaults are settled: platform alerts push only through Applications that opt in, sending is one User per call (no broadcast yet), each recipient gets at most 60 notifications per Application per hour, the inbox keeps 90 days, the notifications.push channel is the only preference, and the push providers are sub-processors.
How it fits together
Four pieces, each owned by a different party:
| Piece | What it is | Who writes it |
|---|---|---|
| Push credential | An Application’s own APNs key, FCM service account, or Web Push (VAPID) key pair. Push providers deliver only for the app that holds these, so every Application brings its own. | The Application’s owner |
| Device | One install of an Application on one device, signed in as one User, plus the provider token that reaches it. | The User’s client, or the Application’s backend |
| Notification | One message to one User: a title, a body, optional data, and how urgent it is. | The Application’s backend (app-scoped key), or the platform itself for platform alerts |
| Inbox | Every notification a User has received, with read and archived state, readable through the API whether or not the push itself arrived. | The User’s client marks items read or archived |
A send writes the Notification and one delivery per eligible device in a single transaction, the same outbox pattern webhooks use. A worker then hands each delivery to the provider. A device a provider reports as gone is deactivated automatically, so a send never keeps targeting a dead token.
Push is a notification channel, not an access-control mechanism. It never grants or revokes anything, and an Application still meets the immediate-revocation requirement through access.revoked webhooks, not pushes.
Endpoints
| Method | Path | Requires | Purpose |
|---|---|---|---|
GET |
/v1/applications/{id}/push-credentials |
the owner, the app’s own key, or applications.manage |
List the providers this Application has configured. Secrets are never returned. |
POST |
/v1/applications/{id}/push-credentials |
the owner or applications.manage, with a User token |
Configure one provider. |
GET |
/v1/applications/{id}/push-credentials/{provider} |
as for the list | Fetch one provider’s configuration. |
PATCH |
/v1/applications/{id}/push-credentials/{provider} |
the owner or applications.manage, with a User token |
Replace key material, change non-secret fields, or re-enable a failed credential. |
DELETE |
/v1/applications/{id}/push-credentials/{provider} |
the owner or applications.manage, with a User token |
Remove a provider. |
GET |
/v1/users/{id}/devices |
self, the app’s own key, or users.list |
List a User’s devices. |
POST |
/v1/users/{id}/devices |
self or the app’s own key | Register a device, or refresh an existing registration. |
GET |
/v1/users/{id}/devices/{deviceId} |
self, the app’s own key, or users.list |
Fetch one device. |
PATCH |
/v1/users/{id}/devices/{deviceId} |
self or the app’s own key | Update a device’s name, app version, or rotated token. |
DELETE |
/v1/users/{id}/devices/{deviceId} |
self, the app’s own key, or users.manage |
Unregister a device. |
POST |
/v1/users/{id}/devices/{deviceId}/test |
self or the app’s own key | Send a test push to one device and wait for the provider’s answer. |
POST |
/v1/users/{id}/notifications |
the app’s own key with notifications.send, or platform notifications.send |
Send one notification to one User. |
GET |
/v1/users/{id}/notifications |
self, the app’s own key, or users.list |
List the User’s inbox. |
GET |
/v1/users/{id}/notifications/unread-count |
self or the app’s own key | Count unread notifications. |
POST |
/v1/users/{id}/notifications/mark-read |
self or the app’s own key | Mark many notifications read at once. |
GET |
/v1/users/{id}/notifications/{notificationId} |
self, the app’s own key, or users.list |
Fetch one notification, with its per-device delivery results. |
PATCH |
/v1/users/{id}/notifications/{notificationId} |
self or the app’s own key | Mark one notification read, unread, or archived. |
“The app’s own key” means any app-scoped API Key scoped to that Application, as on Settings. An app key only ever sees its own Application’s push credentials, devices, and notifications. Anything else returns 404, as though it didn’t exist. It never sees platform alerts, which belong to the User. {id} may be me for a User token (see Conventions → me).
Two ways a client reaches these endpoints. An Application using embedded login holds a platform access token and calls the /v1/users/me/… endpoints directly. An Application using the hosted flow holds only app tokens, which this API doesn’t accept as a Bearer credential, so its client hands the provider token to its own backend, which registers the device with its app key. Both paths produce the same Device. The only difference is that a device registered by an app key isn’t tied to a session.
Push credentials
APNs, FCM, and browser push services deliver a push only when the sender proves it’s the app the token was issued to. So each Application configures its own credentials, and the platform signs every request to the provider with them. There’s at most one credential per provider per Application.
Push credentials are Application configuration, shared by test and live mode the same way the Application row itself is. Test-mode notifications reach only test-mode devices, but they use the same credentials. APNs development builds are handled per device, by environment: sandbox, not by a separate credential.
POST /v1/applications/{id}/push-credentials
// Request — APNs, token-based authentication with a .p8 key
{
"provider": "apns",
"team_id": "ABCDE12345",
"key_id": "XYZ987WVU6",
"topic": "com.substratal.timetrack",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg...\n-----END PRIVATE KEY-----\n"
}
// Request — FCM, HTTP v1 API with a service account
{
"provider": "fcm",
"service_account": {
"type": "service_account",
"project_id": "timetrack-prod",
"private_key_id": "3f1c...",
"private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n",
"client_email": "push-sender@timetrack-prod.iam.gserviceaccount.com"
}
}
// Request — Web Push. The platform generates the VAPID key pair.
{ "provider": "web_push", "subject": "mailto:push@timetrack.substratalapps.com" }
// Response — 201 (APNs)
{
"id": "pcr_01JAH1A2B3C4D5E6F7G8H9J0K1",
"application_id": "app_timetrack",
"provider": "apns",
"status": "active",
"team_id": "ABCDE12345",
"key_id": "XYZ987WVU6",
"topic": "com.substratal.timetrack",
"failure_reason": null,
"failed_at": null,
"last_used_at": null,
"created_at": "2026-10-17T15:00:00Z",
"updated_at": "2026-10-17T15:00:00Z"
}
| Provider | Required | Returned (never the secret) |
|---|---|---|
apns |
team_id (10 characters), key_id (10 characters), topic (the app’s bundle id), private_key (the .p8 file’s contents: a PEM PKCS#8 P-256 key) |
team_id, key_id, topic |
fcm |
service_account (the downloaded JSON key, with type: service_account, project_id, private_key, and client_email) |
project_id, client_email |
web_push |
subject (a mailto: or https: contact URI, sent to browser push services per RFC 8292) |
subject, vapid_public_key (base64url, uncompressed P-256 point) |
private_key,service_account, and the generated VAPID private key are write-only. They’re stored KMS-encrypted, never returned by any endpoint, and never written to an Audit Event, the same treatment as a webhook signing secret.- The material is checked for shape on write (the key parses, the JSON has the required fields), and a failure is
422 invalid_push_credential. It isn’t checked against the provider, because that would make the request depend on Apple’s or Google’s availability. UsePOST …/devices/{deviceId}/testto prove it end to end. vapid_public_keyis what a web client passes asapplicationServerKeytoPushManager.subscribe(). The app’s own key can read it, so a web client can fetch it from its backend.- A second credential for the same provider is
409 push_credential_exists. UsePATCHto replace it. - Writes
push_credential.created.
Credential management is always done by a person, the same rule as API Keys. An API Key, including the Application’s own, can read credentials but never create, change, or remove them (400 user_token_required).
GET /v1/applications/{id}/push-credentials, GET …/{provider}
The list returns every configured provider as { "data": [ … ] } with no pagination, since there are at most three. {provider} is apns, fcm, or web_push. status is one of:
active.failed, after the provider rejected the credential itself (APNs403 InvalidProviderToken, FCM401/403).failure_reasonholds the provider’s reason code andfailed_atwhen it happened. Sends to that provider’s devices are skipped withskip_reason: credential_faileduntil the credential is fixed, the owner is emailed, and apush_credential.failedwebhook fires.
PATCH /v1/applications/{id}/push-credentials/{provider}
// Request — rotate the APNs key
{ "key_id": "NEWKEY1234", "private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" }
Writable: the same fields as on POST for that provider, plus status (only failed → active). Any change to key material also returns a failed credential to active. provider and application_id are fixed (422 read_only_field). The VAPID key pair can’t be rotated in place, because every existing browser subscription is bound to it. Delete and recreate the credential instead. Writes push_credential.updated. Returns 200 with the credential.
DELETE /v1/applications/{id}/push-credentials/{provider}
204. Pending deliveries to that provider are skipped (each delivery’s error is credential_removed), and new sends skip the provider (skip_reason: no_credentials when no other provider can reach the User). Devices are kept, because an APNs or FCM token stays valid and works again once a credential is configured. The exception is web_push: browser subscriptions are bound to the deleted VAPID key, so every web_push device of the Application becomes inactive with inactive_reason: credential_removed. Writes push_credential.deleted. Destructive. See MCP Server → Tool annotations.
Devices
A Device is one install of one Application, signed in as one User, with the provider token that reaches it. The same phone running two Applications is two Devices, because each app gets its own token from the OS.
POST /v1/users/{id}/devices
Called on every app launch and whenever the OS issues a new token, so registration is an idempotent upsert, not a one-time create.
// Request — iOS
{
"application_id": "app_timetrack",
"provider": "apns",
"token": "7f3c9a1e2b4d6f8a0c2e4b6d8f0a2c4e6b8d0f2a4c6e8b0d2f4a6c8e0b2d9f3a",
"environment": "production",
"name": "Jordan's iPhone",
"app_version": "2.4.0"
}
// Request — a browser, with the PushSubscription from PushManager.subscribe()
{
"application_id": "app_timetrack",
"provider": "web_push",
"subscription": {
"endpoint": "https://fcm.googleapis.com/fcm/send/eHk3...:APA91b...",
"keys": { "p256dh": "BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM", "auth": "tBHItJI5svbpez7KI4CCXg" }
},
"name": "Chrome on macOS"
}
// Response — 201 for a new registration, 200 for a refresh of an existing one
{
"id": "dvc_01JAH2B3C4D5E6F7G8H9J0K1M2",
"user_id": "usr_01JAG3Z9X8QS3F6K2M4N5P6R7S",
"application_id": "app_timetrack",
"provider": "apns",
"environment": "production",
"token_hint": "…9f3a",
"name": "Jordan's iPhone",
"app_version": "2.4.0",
"session_id": "ses_01JAG8K4Q9R0S1T2V3V4W5X6Y8",
"status": "active",
"inactive_reason": null,
"last_registered_at": "2026-10-17T15:05:00Z",
"last_delivery_at": null,
"test_mode": false,
"created_at": "2026-10-17T15:05:00Z",
"updated_at": "2026-10-17T15:05:00Z"
}
| Field | Rules |
|---|---|
application_id |
Required. The User must have active access to it right now (allow()), otherwise 403 entitlement_required. An app key may only pass its own Application. |
provider |
Required. apns, fcm, or web_push. No credential needs to exist yet. A device registered before its credential is configured simply isn’t reachable until it is. |
token |
Required for apns (the device token as hex, 64–200 characters) and fcm (the registration token, 1–4,096 characters). Not allowed for web_push. |
subscription |
Required for web_push, and only for it: endpoint (an https URL on a public host, the same SSRF rules as a webhook URL) and keys.p256dh and keys.auth (base64url). |
environment |
apns only, required: production or sandbox (a development build, delivered through api.sandbox.push.apple.com). |
name, app_version |
Optional labels, up to 100 and 50 characters, for the User’s own device list and for support. |
A malformed token or subscription is 422 invalid_device_token.
What makes a registration “the same” device is the token: one Device per (application_id, provider, token) and mode, where a web_push device’s token is its endpoint. Registering an existing token:
- for the same User refreshes it and returns
200.last_registered_atmoves forward,name,app_version, andenvironmenttake the new values, andsession_idbecomes the caller’s current session. Aninactivedevice becomesactiveagain and firesdevice.registered. - for a different User (someone else signed in on that install) deletes the previous registration and creates a new Device for the caller (
201). A device never carries one User’s history over to another. The previous owner’s Device is gone, anddevice.deletedfires for it.
The token itself is never returned. It’s stored KMS-encrypted, and responses carry only token_hint, its last four characters. Device tokens aren’t bearer credentials, but they are personal data, and a client never needs to read one back.
At most 20 active devices per User per Application. Registering a 21st deactivates the one registered longest ago (inactive_reason: limit).
Self-service and app-key registrations write no Audit Event. They’re ordinary account activity, the same as a login.
GET /v1/users/{id}/devices
// Response — 200
{
"data": [ { "id": "dvc_01JAH2B3C4D5E6F7G8H9J0K1M2", "application_id": "app_timetrack", "provider": "apns", "status": "active", "...": "..." } ],
"page": { "next_cursor": null, "has_more": false }
}
Filters: application_id, provider, status, and include_inactive=true. Without an explicit status or include_inactive, only active devices are listed, per Conventions → Filtering. An app key gets only its own Application’s devices, whatever it passes.
PATCH /v1/users/{id}/devices/{deviceId}
// Request — the OS rotated the token
{ "token": "9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b", "app_version": "2.5.0" }
Writable: name, app_version, environment (apns), and the token (token, or subscription for web_push). If the new token is already registered as another Device of the same User and Application, that other registration is deleted, so one token never maps to two Devices. Writing a token reactivates an inactive device. application_id, provider, user_id, session_id, and status are read-only (422 read_only_field). Returns 200 with the Device.
DELETE /v1/users/{id}/devices/{deviceId}
204. Hard delete: the Device is removed, its pending deliveries are dropped, and device.deleted fires. Call this when the User turns notifications off in the app, or from the app’s own sign-out if it registered the device with its app key. users.manage can delete anyone’s device, which writes device.deleted to the audit log. Self-service and app-key deletes aren’t audited. Destructive.
POST /v1/users/{id}/devices/{deviceId}/test
// Request — optional overrides
{ "title": "Test notification", "body": "If you can see this, push works." }
// Response — 200
{
"device_id": "dvc_01JAH2B3C4D5E6F7G8H9J0K1M2",
"status": "sent",
"provider_status": 200,
"error": null,
"duration_ms": 184
}
Sends one push to one device through the real signing and delivery path, waits up to 10 seconds for the provider’s answer, and returns it. status is sent or failed, and error carries the provider’s reason code (for example BadDeviceToken, UNREGISTERED, or InvalidProviderToken) when there is one. It’s for proving a credential and a device work together while integrating.
titledefaults toTest notification from Substratalandbodytonull.- It’s sent whatever the User’s push preference says (you asked for it), and to an
inactivedevice too, so you can check a device the platform deactivated. - It isn’t stored in the inbox and doesn’t count toward the per-recipient limit. It has its own limit of 10 per device per hour, on top of the default per-key bucket. Its result updates the device and credential the same way a real delivery would: an invalid token deactivates the device, and a rejected credential marks it
failed. 409 push_not_configuredif the Application has no credential for the device’s provider.
Sending a notification
POST /v1/users/{id}/notifications
// Request
{
"category": "timesheet.reminder",
"priority": "normal",
"title": "Timesheet due tomorrow",
"body": "Your timesheet for the week of Oct 12 is due Friday at 5 pm.",
"data": { "timesheet_id": "ts_8841" },
"action_url": "com.substratal.timetrack:/timesheets/ts_8841",
"badge": 1,
"sound": "default",
"collapse_key": "timesheet-due-2026-10-12",
"ttl_seconds": 86400
}
// Response — 201
{
"id": "ntf_01JAH3C4D5E6F7G8H9J0K1M2N3",
"user_id": "usr_01JAG3Z9X8QS3F6K2M4N5P6R7S",
"application_id": "app_timetrack",
"source": "application",
"category": "timesheet.reminder",
"priority": "normal",
"title": "Timesheet due tomorrow",
"body": "Your timesheet for the week of Oct 12 is due Friday at 5 pm.",
"data": { "timesheet_id": "ts_8841" },
"action_url": "com.substratal.timetrack:/timesheets/ts_8841",
"badge": 1,
"sound": "default",
"collapse_key": "timesheet-due-2026-10-12",
"ttl_seconds": 86400,
"push": { "requested": true, "status": "queued", "devices_targeted": 2, "devices_sent": 0, "devices_failed": 0, "skip_reason": null },
"state": "unread",
"read_at": null,
"archived_at": null,
"sender": { "type": "api_key", "id": "key_01JAGE5F6G7H8J9K011M2N304P" },
"test_mode": false,
"created_at": "2026-10-17T15:10:00Z",
"updated_at": "2026-10-17T15:10:00Z"
}
The caller is the Application’s own key with notifications.send (an app-confined permission), and the notification is from that Application. A platform key or User holding platform notifications.send may send on behalf of any Application by passing application_id. Platform alerts aren’t sent through this endpoint. The platform raises them itself (see Platform alerts).
| Field | Rules |
|---|---|
title |
Required. 1–120 characters. |
body |
Optional. Up to 1,000 characters. |
category |
Required. What kind of notification this is, for the client’s grouping, actions, and filters: 1–64 characters matching ^[a-z][a-z0-9_]*(\.[a-z0-9_]+)*$. The Application chooses its own vocabulary. The prefixes security., account., and billing. are reserved for platform alerts (422 reserved_category). |
priority |
normal (default) or high. high is for time-sensitive alerts the User needs now. It maps to each provider’s urgent delivery class, and providers throttle apps that overuse it. normal is for other updates. |
push |
Boolean, default true. false writes the notification to the inbox without a push: an update the User should see next time they look, not one that should buzz their phone. |
data |
Optional. Up to 20 keys, each string → string, 2 KB serialized. Delivered to the app with the push and stored with the inbox item. String values only, because FCM’s data payload only carries strings. The key substratal is reserved. |
action_url |
Optional. Where the client should take the User when they open it: an https URL or a private-use URI scheme, the same formats as redirect_uris. Up to 2,048 characters. |
badge |
Optional integer, 0–99,999, for the app icon badge (APNs) or notification count (FCM). null leaves the badge alone. |
sound |
default or null (silent). Defaults to default for high priority and null for normal. |
collapse_key |
Optional. Up to 64 characters of [A-Za-z0-9._-]. A newer notification with the same key replaces an older one still waiting on the device or provider, so the User sees one current status instead of a stack. It doesn’t change the inbox, where both stay. |
ttl_seconds |
How long the provider should keep trying to reach an offline device: 0–2,419,200 (28 days), default 86,400. 0 means deliver now or not at all. It doesn’t affect how long the notification stays in the inbox. |
application_id |
Only for a platform notifications.send caller, and required for one. An app key’s sends are always from its own Application. |
The whole push payload must fit in 4 KB, the limit all three providers share, after the platform adds its own substratal block (see What a device receives). A request that doesn’t fit is 422 notification_too_large, with details.bytes and details.max.
The recipient must have active access to the Application right now (allow() passes, which also requires the User to be active), otherwise 403 entitlement_required. A User who has lost access to an app stops hearing from it at the same moment, whatever the sender believes.
Accepts an optional Idempotency-Key, honored as in Conventions → Idempotency. Send one whenever the send is triggered by an event your backend might process twice, such as a queue message, so a retry doesn’t buzz the User’s phone twice.
A send always creates the notification, even when no push goes out. The response is 201 either way, and push.status says what happened to the push:
push.status |
Meaning |
|---|---|
queued |
Deliveries are on their way to devices_targeted devices. |
sent |
At least one provider accepted it. devices_sent and devices_failed count the outcome per device. “Accepted” means the provider took it, not that the User saw it: neither APNs nor FCM reports display. |
failed |
Every targeted device failed. |
skipped |
No push was attempted. skip_reason says why. |
skip_reason is one of:
push_not_requested: the request setpush: false.push_disabled: the User turned push off, globally or for this Application (see Preferences).no_devices: the User has no active device for this Application.no_credentials: the Application has no credential for any provider the User’s devices use.credential_failed: the matching credentials are allfailed.expired:ttl_secondsran out before any attempt could be made.
The notification is in the User’s inbox in every case. That’s what makes the inbox a reliable place to check, and what lets an Application fall back to another channel when a push can’t go out (see notification.push_failed).
Preferences
There’s no separate preferences resource. Push follows the push channel in the User’s existing Settings notifications map, resolved per Application:
- A resolved
notifications.pushoffalse, from global Settings or from this Application’s AppSettings override, means no push for this Application. The inbox still gets the notification, withskip_reason: push_disabled. - A missing
pushkey counts as on. Registering a device follows the User granting the OS’s own notification permission, which is the real opt-in.
The OS-level permission stays the User’s final word. If they revoke it on the device, the provider reports the token invalid on the next send, and the platform deactivates the device.
Rate limits
Two limits, on top of the default per-key bucket:
- Per sender: 600 sends per minute per API Key.
- Per recipient: 60 notifications per User per Application per rolling hour. Platform alerts don’t count toward it. A send over the limit is
429 rate_limitedwithdetails.scope: "recipient", so a bug in one Application’s backend can’t flood one person’s phone.
The inbox
Every notification a User receives is kept for 90 days, whether or not its push arrived, so a client can always show what the User missed. A client renders it from these endpoints and keeps it in step with what the User does.
GET /v1/users/{id}/notifications
// Response — 200
{
"data": [
{
"id": "ntf_01JAH3C4D5E6F7G8H9J0K1M2N3",
"application_id": "app_timetrack",
"source": "application",
"category": "timesheet.reminder",
"priority": "normal",
"title": "Timesheet due tomorrow",
"body": "Your timesheet for the week of Oct 12 is due Friday at 5 pm.",
"action_url": "com.substratal.timetrack:/timesheets/ts_8841",
"state": "unread",
"created_at": "2026-10-17T15:10:00Z",
"...": "..."
}
],
"page": { "next_cursor": null, "has_more": false }
}
Newest first. Filters: state (unread, read, archived), application_id, source (application or platform), category, priority, created_after, created_before, and include_inactive=true. Archived notifications are excluded unless the caller passes state=archived or include_inactive=true, the same rule as other terminal states in Conventions → Filtering.
Who sees what:
- The User themself sees everything addressed to them: every Application’s notifications and every platform alert.
- An app key sees only its own Application’s notifications. The
application_idfilter is forced, andsource=platformreturns nothing. users.list(support) can read any User’s inbox, for investigations.
GET /v1/users/{id}/notifications/{notificationId}
The full Notification, as in the send response, plus deliveries, one entry per targeted device:
{
"id": "ntf_01JAH3C4D5E6F7G8H9J0K1M2N3",
"push": { "requested": true, "status": "sent", "devices_targeted": 2, "devices_sent": 1, "devices_failed": 1, "skip_reason": null },
"deliveries": [
{ "id": "ndl_01JAH3D4E5F6G7H8J9K0M1N2P3", "device_id": "dvc_01JAH2B3C4D5E6F7G8H9J0K1M2", "provider": "apns", "status": "sent", "attempts": 1, "error": null, "sent_at": "2026-10-17T15:10:02Z" },
{ "id": "ndl_01JAH3D4E5F6G7H8J9K0M1N2P4", "device_id": null, "provider": "fcm", "status": "failed", "attempts": 1, "error": "UNREGISTERED", "sent_at": null }
],
"...": "..."
}
device_id is null once that Device has been deleted. Delivery details are kept for 30 days, after which deliveries is [] and only the push summary remains.
PATCH /v1/users/{id}/notifications/{notificationId}
// Request
{ "state": "read" }
state is the only writable field. Every transition is allowed:
| To | Effect |
|---|---|
read |
Sets read_at (or keeps it). From archived, also clears archived_at. |
unread |
Clears read_at and archived_at. |
archived |
Sets archived_at, and read_at too if it was unset. The notification leaves the default list. |
Returns 200 with the Notification. No Audit Event: this is the User’s own reading state, not an access change.
POST /v1/users/{id}/notifications/mark-read
// Request — everything from TimeTrack up to a point in time
{ "application_id": "app_timetrack", "before": "2026-10-17T15:10:00Z" }
// Response — 200
{ "updated_count": 4 }
Marks every unread notification matching the filters as read. Each filter is optional: application_id, source, category, and before (only notifications created at or before this time, so items that arrive while the User is looking aren’t swept up). An empty body marks everything the caller can see. An app key’s application_id is forced to its own.
GET /v1/users/{id}/notifications/unread-count
// Response — 200
{
"unread_count": 3,
"applications": [
{ "application_id": "app_timetrack", "unread_count": 2 },
{ "application_id": null, "unread_count": 1 }
]
}
Unread, unarchived notifications. application_id: null is the platform alerts. Accepts the application_id and source filters. An app key gets only its own Application’s count. A client typically calls it on launch and after handling a push, to set its badge.
Platform alerts
The platform raises a small, fixed set of notifications itself, about the User’s account rather than any one Application. They use the same Notification object, with source: platform, application_id: null, sender: {"type": "system", "id": "system"}, and a category from this catalog:
category |
priority |
Raised when | data |
|---|---|---|---|
security.new_sign_in |
high |
A login creates a session from a user agent not seen in the User’s sessions during the last 90 days. | session_id, user_agent, ip_address |
security.password_changed |
high |
POST /v1/users/{id}/password, or a password reset. |
— |
security.email_changed |
high |
An email change completes. | — |
security.mfa_disabled |
high |
TOTP is turned off by the User, or reset by support. | — |
security.session_reuse_detected |
high |
A reused refresh token revokes a session (refresh_token_reused). |
session_id |
account.organization_invitation |
normal |
A pending membership is created for an existing User (Organizations → Adding members). |
organization_id, invited_by |
account.access_granted |
normal |
The User gains resolved access to an Application (the same moment access.granted fires). Raised only for access a developer or admin grants (purchase, trial, admin_grant, or org_seat), never for platform-managed free or subscription Entitlements: signing up, subscribing to Standard, or a new free Application would otherwise push one alert per app. Also not raised within 60 seconds of signup. |
application_id |
billing. categories are reserved for subscription alerts, such as a failed payment. None are sent yet; Stripe emails the customer directly.
These run alongside the emails the platform already sends for the same events, and don’t replace them. A User who has never registered a device still gets the email.
Which devices get a platform alert’s push. A device belongs to an Application, and only an Application’s own credentials can reach it. So a platform alert is pushed only to devices of Applications that have opted in with receives_platform_notifications: true, typically a first-party Substratal app. Without that, a platform alert goes to the inbox (and the email above) but makes no push, and skip_reason is no_devices. Opting in is the owner’s choice, because the alert arrives under that app’s name and icon. The push preference applies per device, resolved for that device’s Application.
Targets are chosen when the alert is raised. An action that also revokes sessions, such as a password reset or a detected token reuse, raises its alert before the revocation in the same transaction. The User’s devices still get the warning, even though they’re about to be signed out. That exception applies only to security.* alerts. Any other pending delivery to a device that has since become inactive is skipped.
Platform alerts never reach an Application through webhooks, and an app key can’t list them. They’re between the platform and the User.
What a device receives
The platform builds each provider’s payload from the Notification. Every payload carries a substratal block, so the app can match the push to its inbox item and mark it read:
{
"substratal": {
"notification_id": "ntf_01JAH3C4D5E6F7G8H9J0K1M2N3",
"application_id": "app_timetrack",
"source": "application",
"category": "timesheet.reminder",
"action_url": "com.substratal.timetrack:/timesheets/ts_8841"
}
}
| Notification field | APNs | FCM (HTTP v1) | Web Push |
|---|---|---|---|
title, body |
aps.alert.title, aps.alert.body |
message.notification.title, .body |
In the encrypted JSON payload |
priority: high / normal |
apns-priority: 10 with aps.interruption-level: time-sensitive / apns-priority: 5 with active |
android.priority: HIGH / NORMAL |
Urgency: high / normal |
ttl_seconds |
apns-expiration |
android.ttl |
TTL header |
collapse_key |
apns-collapse-id |
android.collapse_key |
Topic header |
badge |
aps.badge |
android.notification.notification_count |
In the payload |
sound |
aps.sound |
android.notification.sound |
Not applicable |
category |
aps.category, so an app can register actionable categories |
In message.data |
In the payload |
data and the substratal block |
Top-level keys beside aps |
message.data, with substratal as a JSON string |
In the payload |
Web Push payloads are encrypted for the subscription’s keys (RFC 8291, aes128gcm), and every request is signed with the Application’s VAPID key (RFC 8292). APNs requests use token-based authentication with the Application’s key, and FCM requests use OAuth 2.0 access tokens minted from its service account. The platform caches and refreshes each of these, so an Application only supplies the long-lived credential.
Keep sensitive content out of title, body, and data. Apple, Google, and the browser vendors’ push services carry the payload, and a lock screen shows the title and body to anyone holding the phone. For anything private, send a generic title, put an id in data, and let the app fetch the details once it’s open and authenticated. The payload is also what the Compliance page counts these providers as sub-processors for.
Delivery
- Latency target: p95 under 10 seconds from the committing write to the first provider attempt, the same as webhooks. Deliveries are written in the same transaction as the notification, so a committed send can never lose its push.
- Retries: a provider
429, a5xx, or a timeout (5 seconds) is retried at +30 seconds, +2 minutes, and +10 minutes, for 4 attempts in total, withinttl_seconds. Then that delivery isfailed. A provider’sRetry-Afteris honored when it’s longer. - A token the provider reports as gone deactivates the device (
inactive_reason: unregistered) and firesdevice.deactivated. That’s APNs410 Unregisteredor400 BadDeviceToken/DeviceTokenNotForTopic, FCM404 UNREGISTEREDor a400 INVALID_ARGUMENTnaming the token, and Web Push404/410. Nothing is retried for that device. - A credential the provider rejects marks it
failed, as described under Push credentials. Every pending delivery that depends on it is skipped. - No ordering guarantee, and a provider may deliver late or not at all. Clients should treat a push as a prompt to refresh from the API, not as the source of truth.
collapse_keyis the tool for “show only the latest”. - No delivery receipts.
sentmeans the provider accepted the push. Whether the User saw it isn’t knowable from the provider, and the inbox’sreadstate is the only “seen” signal.
Sessions and device lifecycle
A device a User registers with their own token is tied to the session that registered it (session_id), so signing out stops its pushes:
| Event | Effect on devices |
|---|---|
| Logout, or revoking that session | Devices registered under that session become inactive (session_revoked). Logout from every session deactivates all of them. |
| Password reset, refresh-token reuse, or MFA reset (each revokes sessions) | The same, after the security.* alert is raised. |
| The User is suspended or soft-deleted | Every device they hold becomes inactive (user_suspended or user_deleted), including devices an app key registered. |
| The User signs in again on the same install | The client re-registers its token, which reactivates the device under the new session. |
An app-key registration (session_id: null) |
Not affected by logout, because the platform can’t see the app’s own sessions. The Application’s backend should DELETE the device when its user signs out. |
| No re-registration for 270 days | inactive (stale). Providers expire tokens from installs that are never opened, so a client should re-register on launch, at most once a day. |
The Application’s web_push credential is deleted |
Its web_push devices become inactive (credential_removed). |
| The User loses access to the Application | Devices stay active, but sends to the User are rejected (403 entitlement_required) until access returns. |
An inactive device is deleted 30 days after it was deactivated, unless it’s re-registered first.
Test mode
A test-mode key registers test-mode devices and sends test-mode notifications. Test-mode notifications reach only test-mode devices, and live notifications never reach a test-mode device, the same isolation as every other record (see API Keys → Test vs. live). Pushes are real: a test-mode delivery goes through the actual provider, using the Application’s shared credentials. Point test-mode devices at development builds (environment: sandbox on APNs) so test traffic never reaches a production install.
Webhook events
Five events tell an Application’s backend about its devices, credentials, and failed pushes. They’re delivered to the Application’s webhook subscriptions like any other event, never include a token or secret, and are scoped to their application_id:
| Event | Fires when | data |
|---|---|---|
device.registered |
A Device is created, or an inactive one is reactivated. |
device |
device.deactivated |
A Device becomes inactive. |
device (with inactive_reason) |
device.deleted |
A Device is deleted, by DELETE, by a registration of its token for another User, or 30 days after deactivation. |
device (last state) |
notification.push_failed |
One of the Application’s own notifications ends with push.status failed, or skipped for no_devices, no_credentials, or credential_failed. Not for push_disabled or push_not_requested, which are choices rather than failures, and never for a platform alert. |
notification_id, user_id, category, push |
push_credential.failed |
A credential becomes failed. |
provider, failure_reason |
notification.push_failed is the hook for falling back to another channel, such as email from your own backend, when a push can’t go out.
Errors specific to this resource
| Code | Status | When |
|---|---|---|
push_credential_not_found |
404 | {provider} isn’t configured for the Application, or isn’t one of apns, fcm, web_push. |
push_credential_exists |
409 | POST for a provider the Application already has. |
invalid_push_credential |
422 | Key material that doesn’t parse, or a service account missing a required field. |
push_not_configured |
409 | A device test for a provider the Application has no credential for. |
device_not_found |
404 | {deviceId} doesn’t resolve, isn’t the User’s, or (for an app key) belongs to another Application. |
invalid_device_token |
422 | A token or subscription in the wrong format for its provider, or a web_push endpoint that isn’t a public https URL. |
notification_not_found |
404 | {notificationId} doesn’t resolve or isn’t visible to the caller. |
reserved_category |
422 | An Application’s send uses a security., account., or billing. category. |
notification_too_large |
422 | The push payload would exceed 4 KB. details.bytes and details.max say by how much. |
entitlement_required |
403 | The recipient (or the User registering a device) doesn’t have active access to the Application. |
user_token_required |
400 | An API Key tried to create, change, or remove a push credential, or used me. |
read_only_field |
422 | A PATCH touched a read-only field, such as a device’s provider or a credential’s application_id. |
invalid_status_transition |
409 | A credential PATCH status other than failed → active. |
rate_limited |
429 | Over the per-sender or per-recipient limit. details.scope is sender or recipient. |
not_implemented |
501 | Every operation on this page, until it’s built. See the status note at the top. |