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:
- Your mobile app obtains the OS push token (APNs / FCM / Web Push).
- The app posts that token to your own API, authenticated by your own session.
- Your backend calls Zyphr, deriving
user_idfrom 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 clientsThe @zyphr-dev/node-sdk package exports two clients, and only one can register devices:
| Client | Credential | Device API |
|---|---|---|
Zyphr | secret key (zy_live_*) | ✅ yes — server-side only |
ZyphrClient | publishable 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
| Parameter | Type | Required | Description |
|---|---|---|---|
user_id | string | Yes | Your identifier for the user (max 255 chars) |
platform | string | Yes | Device platform: ios, android, web |
token | string | Yes | Platform-specific push token (max 4096 chars) |
metadata | object | No | Arbitrary 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.
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..."
}'
| Credential | Device 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) |
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.
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.
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.
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_idis unique per project, so the sameexternal_idin 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)
}
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.
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"
| Parameter | Type | Description |
|---|---|---|
user_id | string | Filter by user ID |
platform | string | Filter by platform: ios, android, web |
limit | number | Results per page (default: 50, max: 100) |
offset | number | Pagination 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
| Event | Meaning |
|---|---|
push.sent | Accepted by the provider |
push.delivered | Provider confirmed handoff to the device |
push.failed | Delivery failed |
push.expired | Token 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_idserver-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 polllistDevicesfor 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
| Method | Endpoint | Scopes | Description |
|---|---|---|---|
POST | /v1/devices | push:write | Register a device |
GET | /v1/devices | push:read | List devices |
GET | /v1/devices/stats | push:read | Get device statistics |
GET | /v1/devices/:id | push:read | Get a device |
DELETE | /v1/devices/:id | push:write | Delete a device |
DELETE | /v1/devices/user/:user_id | push:write | Delete all user devices |
Next Steps
- Push Notifications - Send push notifications to registered devices
- Subscribers - Profile and preference management (not required for push)
- In-App Messaging - Add an in-app notification center