Skip to main content

Device Management

Register and manage devices for push notification delivery. Zyphr tracks device tokens for iOS (APNs), Android (FCM), and Web Push, enabling targeted push delivery to specific devices or all of a user's devices.

Overview

Before sending push notifications, devices must be registered with Zyphr. Each device has a platform-specific token obtained from the push notification service (APNs, FCM, or Web Push).

Register devices from your backend

Device registration uses your secret API key (zy_live_* / zy_test_*), so it must happen server-side. The supported pattern is:

  1. Your mobile app obtains the OS push token (APNs / FCM / Web Push).
  2. The app posts that token to your own API, authenticated by your own session.
  3. Your backend calls Zyphr, deriving user_id from the authenticated session.
// In YOUR backend — never in the mobile app
import { Zyphr } from '@zyphr-dev/node-sdk';

const zyphr = new Zyphr({ apiKey: process.env.ZYPHR_API_KEY });

app.post('/push-token', requireAuth, async (req, res) => {
await zyphr.devices.registerDevice({
userId: req.user.id, // from YOUR session, never the request body
platform: 'ios',
token: req.body.pushToken,
metadata: { appVersion: req.body.appVersion },
});
res.json({ ok: true });
});
Zyphr and ZyphrClient are different clients

The @zyphr-dev/node-sdk package exports two clients, and only one can register devices:

ClientCredentialDevice API
Zyphrsecret key (zy_live_*)✅ yes — server-side only
ZyphrClientpublishable app key (za_live_pub_*)❌ no

ZyphrClient is the browser / React Native client for end-user auth. It intentionally does not expose devices — the device API needs the secret key, which must never ship inside an app bundle.

Deriving user_id server-side also matters for correctness: a client-supplied user_id would let any caller register a device against another user's account.

Register a Device

curl -X POST https://api.zyphr.dev/v1/devices \
-H "X-API-Key: zy_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"user_id": "user_123",
"platform": "ios",
"token": "abc123def456...",
"metadata": { "appVersion": "2.1.0" }
}'

Send a Push Notification

Once devices are registered, send by user ID — Zyphr delivers to all of that user's registered devices:

curl -X POST https://api.zyphr.dev/v1/push \
-H "X-API-Key: zy_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"user_id": "user_123",
"title": "New Message",
"body": "You have a new notification"
}'

Registering Devices

Registration Parameters

ParameterTypeRequiredDescription
user_idstringYesYour identifier for the user (max 255 chars)
platformstringYesDevice platform: ios, android, web
tokenstringYesPlatform-specific push token (max 4096 chars)
metadataobjectNoArbitrary JSON stored with the device

Use metadata for anything else you want to keep alongside the registration — app version, device model, build channel. It is stored and returned verbatim, and is not interpreted during delivery.

What user_id means

user_id is an opaque string you choose. Zyphr stores it as-is and does not validate it against any other table — there is no foreign key to Zyphr users or subscribers, and no Subscriber record is created for you.

Delivery is a plain match on that same string: POST /v1/push with user_id: "user_123" finds every device registered under exactly "user_123".

Recommendation: use your own application's user ID. Whatever you register with is what you send to, so no mapping table is required.

You do not need Subscribers for push

Subscribers are a separate feature for profile and preference management. Creating a Subscriber and registering devices under the subscriber UUID works — the field is opaque — but it forces you to store and look up that UUID on every send. Unless you already use Subscribers for other reasons, register with your own user ID directly.

Scoping devices to an application

By default a device registered with your secret API key (zy_live_* / zy_test_*) is scoped to the project, and push delivery resolves the project's provider configuration.

If you ship more than one app — several titles, or white-labelled builds — you can scope a device to a specific application and environment, so each one delivers through its own APNs/FCM credentials without needing a separate Zyphr project.

The hierarchy

account
└── project secret API keys (zy_live_* / zy_test_*)
└── application application keys (za_*)
└── environment test | live

Provider configurations may be attached at any of these levels. At send time Zyphr picks the most specific configuration available for the device: environment → application → project.

Registering a scoped device

Present application credentials instead of your secret API key:

curl -X POST https://api.zyphr.dev/v1/devices \
-H "X-Application-Key: za_live_pub_your_key" \
-H "X-Application-Secret: za_live_sec_your_secret" \
-H "Content-Type: application/json" \
-d '{
"user_id": "user_123",
"platform": "ios",
"token": "abc123..."
}'
CredentialDevice is scoped to
zy_live_* / zy_test_* (secret API key)project — resolves the project-level provider config
za_test_pub_* / za_live_pub_* (environment-scoped)that application and environment
za_pub_* (legacy, application-level)that application only — no environment (see below)
Scope comes from the credential, never from the request body

There is no application_id parameter on device registration or push send, and this is deliberate. The application is derived from the credential you present.

If the application were a body parameter, a typo or a shared code path could register a device against the wrong application. The send would then dispatch that application's APNs credentials, which Apple rejects as DeviceTokenNotForTopic — because the APNs topic comes from the credentials, not from the request. Deriving scope from the credential makes that mistake unrepresentable rather than merely validated.

Practically: to support multiple applications, resolve a different credential per application in your backend. Do not look for a parameter to pass.

Provision environment-scoped keys before registering any devices

The device→environment binding is set at registration time and is not retroactive.

A device registered with a legacy application-level key (za_pub_*) is stored with no environment. It falls back to the application-level provider configuration — no matter how carefully you configure an environment-scoped one afterwards.

Nothing fails at registration. The symptom appears at the first send, as an APNs rejection, disconnected from a registration that may have happened days earlier and possibly by a different team. The only fix is to re-register the affected devices.

If you intend to use environment-scoped configuration, create the environment-scoped keys (za_test_pub_* / za_live_pub_*) first, and register every device with them from the start.

Scoping a send

Send with the same application credentials to narrow delivery to that application:

curl -X POST https://api.zyphr.dev/v1/push \
-H "X-Application-Key: za_live_pub_your_key" \
-H "X-Application-Secret: za_live_sec_your_secret" \
-H "Content-Type: application/json" \
-d '{ "user_id": "user_123", "title": "Hi", "body": "..." }'

The send reaches devices registered under that application, plus any device registered without an application (see below). It never reaches a device belonging to a different application.

A project secret key (zy_live_* / zy_test_*) carries no application context, so it still reaches every device for that user_id across the project — unchanged.

Application credentials are accepted on POST /v1/push for sending only. Listing push history, reading messages, and topic subscribe/unsubscribe require the project secret key.

Devices registered without an application still receive scoped sends

A device with no application — registered with a project key, or before you adopted application credentials — is included in every application-scoped send for its user_id.

This is deliberate. "No application" means unscoped, not belongs to another application, so excluding these devices would silently stop delivery to your entire pre-existing install base the moment you adopted application credentials.

The practical consequence: until your install base has re-registered under application credentials, an application-scoped send still reaches those older devices. Re-register them to get full separation.

Applications do not scope subscriber identity

Application scoping applies to provider credentials and push delivery. It is not a full tenant boundary:

  • Subscribers are not application-scoped. If you use Subscribers, external_id is unique per project, so the same external_id in two applications is a single subscriber.
  • Other project-scoped resources — templates, topics, push history — remain shared across applications in the project.

If you need genuinely separate namespaces per app, use a separate Zyphr project per app.

Platform-Specific Tokens

iOS (APNs)

func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
// POST to YOUR backend, which calls Zyphr with the secret key
sendTokenToBackend(token)
}
APNs sandbox vs production

A locally dev-signed build and a TestFlight / App Store build receive tokens from different APNs environments, and those tokens are not interchangeable — sending a sandbox token through a production provider fails permanently with BadDeviceToken, and Zyphr will then prune that device (see Dead tokens).

Always use a separate bundle ID per APNs environment. To point each bundle ID at its own APNs credentials, register its devices with an environment-scoped application key — see Scoping devices to an application below.

metadata is never consulted during delivery, so you cannot use it to select credentials.

Android (FCM)

FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (task.isSuccessful) {
val token = task.result
sendTokenToBackend(token)
}
}

Web Push

const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: vapidPublicKey,
});

await fetch('/api/register-device', {
method: 'POST',
body: JSON.stringify({ platform: 'web', token: JSON.stringify(subscription) }),
});

Device lifecycle

Token refresh

Push tokens rotate. Re-register whenever the OS hands you a new one:

async function onTokenRefresh(newToken: string) {
await zyphr.devices.registerDevice({
userId: currentUser.id,
platform: 'android',
token: newToken,
});
}

Registration is an upsert keyed on (project, token). Re-registering the same token updates the existing row rather than creating a duplicate, so registering on every app launch is safe and idempotent.

Token reassignment — last writer wins

When user A logs out of a device and user B logs in, the OS usually hands your app the same token. Registering it under B's user_id moves the token to B:

1. register(token: "abc", user_id: "user_A")   -> device belongs to A
2. register(token: "abc", user_id: "user_B") -> device now belongs to B
3. sendPush(user_id: "user_A") -> does NOT reach this device

The previous association does not survive, so there is no window where one device is registered to two users. A push addressed to A cannot land on a device B is holding.

Logging out

Because re-registration fully supersedes the previous owner, deleting on logout is not required for that handover to be safe.

It is required in one case: if nobody logs in on that device afterwards, the token stays mapped to the previous user until something overwrites it — so a push to that user would still arrive on a device they no longer use. Delete the device on logout to close that window:

await zyphr.devices.deleteDevice(deviceId);

If you do not track Zyphr device IDs locally, list the user's devices and match on the token:

const { data } = await zyphr.devices.listDevices(userId);
const device = data?.find((d) => d.token === pushToken);
if (device?.id) await zyphr.devices.deleteDevice(device.id);

Dead tokens

When a user deletes your app, the provider starts rejecting its token — APNs returns Unregistered or BadDeviceToken, FCM and Web Push return their equivalents.

Zyphr detects this on send and deletes the device row automatically. There is no active or status field: pruning is a hard delete, so a dead device simply stops appearing in listDevices.

The corresponding push message is marked expired and a push.expired webhook fires carrying the provider error. Drive any local cleanup off that event rather than polling:

{
"id": "evt_...",
"type": "push.expired",
"api_version": "2026-02-01",
"created_at": "2026-08-17T02:44:57.000Z",
"data": {
"push_message_id": "...",
"event_type": "push.expired",
"provider": "apns",
"error_message": "The device token is no longer active for the topic.",
"timestamp": "2026-08-17T02:44:57.000Z"
}
}

error_code is absent on push.expired — the invalid-token path records only the provider's message, and unset fields are omitted from the JSON rather than sent as null. Read error_message, and treat error_code as optional on every push event.

The payload does not identify the device

data carries push_message_id, not a device id or token. To map an expiry back to a device you must have recorded which device each push_message_id was sent to when you sent it.

Because pruning is automatic, a Zyphr device ID you stored locally can stop resolving. Treat a 404 from deleteDevice as already-cleaned-up rather than an error.

Managing Devices

List Devices

curl "https://api.zyphr.dev/v1/devices?user_id=user_123&platform=ios&limit=25" \
-H "X-API-Key: zy_live_your_api_key"
ParameterTypeDescription
user_idstringFilter by user ID
platformstringFilter by platform: ios, android, web
limitnumberResults per page (default: 50, max: 100)
offsetnumberPagination offset

Get a Device

curl https://api.zyphr.dev/v1/devices/DEVICE_ID \
-H "X-API-Key: zy_live_your_api_key"

Delete a Device

curl -X DELETE https://api.zyphr.dev/v1/devices/DEVICE_ID \
-H "X-API-Key: zy_live_your_api_key"
await zyphr.devices.deleteDevice(deviceId);

Delete All User Devices

curl -X DELETE https://api.zyphr.dev/v1/devices/user/user_123 \
-H "X-API-Key: zy_live_your_api_key"
await zyphr.devices.deleteUserDevices('user_123');

Device Statistics

curl https://api.zyphr.dev/v1/devices/stats \
-H "X-API-Key: zy_live_your_api_key"

Returns:

{
"data": {
"total": 1432,
"byPlatform": { "ios": 812, "android": 590, "web": 30 },
"active30Days": 1104
}
}

active30Days counts devices whose last_active_at falls inside the last 30 days.

Push webhook events

EventMeaning
push.sentAccepted by the provider
push.deliveredProvider confirmed handoff to the device
push.failedDelivery failed
push.expiredToken is dead — the device has been pruned

See Webhook Event Types for subscribing.

Best Practices

  • Register on every app launch — tokens rotate, and registration is an idempotent upsert
  • Derive user_id server-side — never from the request body
  • Delete on logout — closes the window where a push reaches a device the user has left
  • Drive cleanup off push.expired — don't poll listDevices for dead tokens
  • Separate bundle ID per APNs environment — sandbox and production tokens are not interchangeable; scope each with an environment-scoped application key

API Reference

MethodEndpointScopesDescription
POST/v1/devicespush:writeRegister a device
GET/v1/devicespush:readList devices
GET/v1/devices/statspush:readGet device statistics
GET/v1/devices/:idpush:readGet a device
DELETE/v1/devices/:idpush:writeDelete a device
DELETE/v1/devices/user/:user_idpush:writeDelete all user devices

Next Steps