@conapps/push-sdk
v0.1.1
Published
TypeScript SDK for the platform Push Notification API — register devices, send notifications, manage categories and consent.
Maintainers
Readme
@conapps/push-sdk
Typed client for the platform's Push Notification API — register devices, send notifications, manage categories and consent, read engagement. Node 18+, any JavaScript server runtime.
npm install @conapps/push-sdkServer-side. Sending a push and registering a device both need workspace credentials, so this SDK belongs in your backend. Your mobile app talks to your backend, which talks to this. For the device side of a Flutter app there is
push_notification_kit.
The shape of the thing
Three parties, and mixing them up is the usual first hour:
| | Owns | Example |
|---|---|---|
| Firebase / Apple | the transport and the device token | FCM, APNs |
| This service | who to send to, when, and whether they consented | categories, quiet hours, caps |
| Your product | the identity of the person | userRef: "user-42" |
The service never learns who user-42 is. Everything addressed to a person —
sends, preferences, the GDPR purge — is keyed on your id, so it has to be the
same string your app already uses.
Before you write any code: four values
| What | Where it comes from |
|---|---|
| Endpoint | the API gateway, e.g. https://api.conapps.net |
| Workspace id | the address bar of your workspace in the backoffice |
| Application id | Push → Applications → the app you created (and gave its Firebase service account) |
| Client id + secret | Settings → API integrations, scopes push-notification:read + :write |
There is no
push.host. Unlike the CMS Delivery API, this service has no public ingress of its own — every call goes through the gateway, and the SDK appends/api/v1/workspaces/{workspaceId}/services/push-notificationfor you. Pointendpointat the gateway, nothing else.
An application is one Firebase project. Create it in the backoffice and upload the service-account JSON there; the SDK can create one too, but the credentials are what make it able to send, and those are easier to paste in a form than to plumb through a deploy.
Quick start
import { createPushClient } from '@conapps/push-sdk';
const push = createPushClient({
endpoint: 'https://api.conapps.net',
workspaceId: 'ws_4a7d',
applicationId: 'app_1f2e',
auth: {
issuer: 'https://auth.conapps.net/realms/platform',
clientId: process.env.PUSH_CLIENT_ID!,
clientSecret: process.env.PUSH_CLIENT_SECRET!,
},
});
// 1. Your backend receives the device token from your app (see below) and
// registers it. Idempotent — call it on every app start and token rotation.
await push.registerDevice({
token: fcmTokenFromYourApp,
platform: 'ios',
userRef: 'user-42',
locale: 'de-DE',
});
// 2. Send.
const { id } = await push.sendToUser('user-42', {
title: 'Your route is live',
body: 'Three people joined already.',
category: 'participation_invite',
deepLink: 'myapp://routes/42',
});
// 3. `queued` is not `delivered` — the per-recipient outcome lands here.
const detail = await push.getMessage(id);
console.log(detail.status, detail.sentCount, detail.throttledCount, detail.suppressedCount);The SDK fetches the token, caches it, refreshes it before expiry and retries once if the server rejects it anyway. See Authentication below for why you should not pass a token string.
Where the device token comes from
This is the step people miss, and it is not optional: your app cannot call this
SDK. Registering a device needs the workspace client_secret, which must never
ship inside an app — anyone who unpacks the binary would hold a password for your
whole workspace. So the token makes one hop you have to build yourself:
┌──────────────┐ 1. FCM/APNs token ┌──────────────┐ 3. registerDevice() ┌──────────┐
│ your app │ ───────────────────▶ │ your backend │ ────────────────────▶ │ platform │
│ (iOS/Android)│ 2. your own HTTP │ (this SDK) │ holds client_secret └──────────┘
└──────────────┘ endpoint └──────────────┘- The OS hands your app a token (Firebase
getToken(), APNsdidRegisterForRemoteNotificationsWithDeviceToken). - Your app POSTs it to an endpoint you write, authenticated with your own session or JWT — the same way it calls the rest of your API.
- That endpoint calls
registerDevice.
Step 2 does not exist until you build it. There is no shortcut.
Your endpoint
// POST /api/devices — your own auth, your own route
app.post('/api/devices', requireLogin, async (req, res) => {
await push.registerDevice({
token: req.body.token, // the FCM/APNs token from the app
platform: req.body.platform, // 'ios' | 'android' | 'web'
userRef: req.user.id, // from YOUR session — never from the body
locale: req.body.locale,
appVersion: req.body.appVersion,
});
res.sendStatus(204);
});Take
userReffrom the session, never from the request body. A client that may name its ownuserRefcan register its device against somebody else's id and receive that person's notifications.
Your app
// Pseudocode — the shape is the same in Swift, Kotlin and Flutter
const token = await messaging().getToken();
await api.post('/api/devices', { token, platform: 'ios', locale: deviceLocale });
// And again whenever the OS rotates it — this fires without the app restarting:
messaging().onTokenRefresh((t) =>
api.post('/api/devices', { token: t, platform: 'ios' }),
);onTokenRefresh is easy to forget and fails silently: the old token keeps being
accepted by the provider for a while, then quietly stops, and that user simply
receives nothing. Nothing in the platform can detect it — from here a rotated
token looks like a user who uninstalled.
On sign-out, call unregisterDevice(token) from your backend. Otherwise the next
person to use that device gets the previous user's notifications.
For Flutter,
push_notification_kitdoes the app side — permission prompt,getToken,onTokenRefresh, and the POST to your endpoint. It still needs you to build the endpoint.
Authentication
An access token from this platform lives about five minutes. Handing the SDK a token string works for your first test run and then fails forever, with a 401 that reads like a wrong secret:
// ✗ Works for five minutes.
createPushClient({ …, token: process.env.PUSH_TOKEN! })
// ✓ Let the SDK own it.
createPushClient({ …, auth: { issuer, clientId, clientSecret } })
// ✓ Or bring your own source (a secrets broker, another service, a test double).
createPushClient({ …, token: async () => broker.currentToken() })Scopes: push-notification:read to read, :write to register devices and send,
:admin for applications and workspace settings. clientSecret is a password for
the workspace — never in a mobile app, never in a browser bundle.
The provider is exported, so several clients can share one token:
import { createTokenProvider, createPushClient } from '@conapps/push-sdk';
const token = createTokenProvider({ issuer, clientId, clientSecret });
const prod = createPushClient({ endpoint, workspaceId, token, applicationId: 'app_prod' });
const beta = createPushClient({ endpoint, workspaceId, token, applicationId: 'app_beta' });What actually happens to a send
send() returns { id, status: 'queued' }. The fan-out runs on a worker, and
between "queued" and a phone lighting up there are four places a recipient can
drop out — all of them visible on the message afterwards, none of them an error:
| Counter | Meaning | What to do |
|---|---|---|
| targetedCount | recipients after audience resolution | if 0, nobody in that audience has a registered device |
| suppressedCount | opted out of this category | expected; only a transactional category bypasses it |
| throttledCount | held by a guardrail — daily cap, minimum spacing, quiet hours | expected; the message is deferred, not lost |
| failedCount | the provider refused the token | dead tokens are disabled automatically |
| sentCount | handed to FCM/APNs | not a read receipt |
const { deliveryBreakdown, ...m } = await push.getMessage(id);
// deliveryBreakdown: { sent: 812, throttled: 14, failed: 3 }The states, in order
queued ──▶ processing ──▶ completed
│
├──▶ cancelled failedqueued is what send() returns; the worker moves it to processing and then
to a terminal state. Poll until status is one of completed, failed or
cancelled — not until it stops being queued. A loop written the second
way gives up the moment it first sees processing and reports a message full of
zeroes as the final result. The type is MessageStatus, so a switch over it
is exhaustive.
import type { MessageStatus } from '@conapps/push-sdk';
const TERMINAL: MessageStatus[] = ['completed', 'failed', 'cancelled'];
let m = await push.getMessage(id);
while (!TERMINAL.includes(m.status)) {
await new Promise((r) => setTimeout(r, 500));
m = await push.getMessage(id);
}A message that reaches nobody is usually one of two things: the audience has no
devices, or the category is one most people opted out of. Both are counters, not
errors — nothing will throw.
Consent is not optional plumbing
await push.createCategory({
key: 'participation_invite',
label: 'Invitations',
transactional: false, // marketing: opt-out applies
defaultOptedIn: true,
});
await push.getPreferences('user-42');
// { globalMuted: false, categories: [{ key, label, effectiveOptedIn, explicit, … }] }
await push.setPreference('user-42', { category: 'participation_invite', optedIn: false });
await push.unsubscribe('user-42'); // everything non-transactional
await push.forgetUser('user-42'); // GDPR purge, irreversibleA send without a category belongs to no category and therefore reaches
everyone who is not globally muted. That is rarely what a marketing send wants —
name a category.
⚠ A misspelled category is the same thing, reached by accident — and the service accepts it.
category: 'route_update'when you meant'route_updates'is not refused: an unknown key behaves exactly like no key at all, so the message goes to people who opted out. There is no error, no log line, andsuppressedCount: 0reads as "nobody objected". Only a global mute still catches it; a per-category opt-out — the normal case — does not.Until the service validates the key, guard it yourself. The keys are one call away:
const keys = new Set((await push.listCategories()).map((c) => c.key)); if (!keys.has(category)) throw new Error(`Unknown push category: ${category}`);Better still, generate the union from your own constants and never pass a bare string.
Scheduling
await push.sendToUser('user-42', { title, body, scheduledFor: '2026-09-20T18:00:00Z' });
await push.sendToUser('user-42', { title, body, delaySeconds: 3600 });
await push.cancelMessage(id); // a dispatch already in flight stops at its next chunkscheduledFor and delaySeconds are mutually exclusive — the SDK refuses both
rather than letting the server pick. Quiet hours are evaluated in the workspace's
timezone (getSettings().timezone, read-only, owned by the core).
Engagement
await push.recordEvent({ messageId, token: deviceToken, type: 'clicked', actionId: 'accept' });
const overview = await push.analyticsOverview({ from: '2026-09-01', to: '2026-09-17' });Idempotent, and a click implies an open — report both without double counting.
Errors
import {
PushError, PushAuthError, PushNotFoundError,
PushQuotaError, PushRateLimitError, PushValidationError,
} from '@conapps/push-sdk';Everything this SDK throws extends PushError. One catch on the base
class is complete — the subclasses are for telling cases apart, not for making
the list exhaustive.
| Class | When | The useful field |
|---|---|---|
| PushError | base class, and anything not listed below | .status, .url, .body |
| PushValidationError | the SDK refused before sending | — |
| PushConnectionError | the request never arrived (DNS, refused, timeout, TLS) | .code, .endpoint |
| PushTokenError | the token endpoint refused the credentials | .tokenUrl, .originalError |
| PushAuthError | 401/403 from the guard | .reason — which cause it is |
| PushQuotaError | 403 from the quota check | .dimension — push_sends_monthly or active_devices |
| PushNotFoundError | 404 | usually an applicationId from another workspace |
| PushRateLimitError | 429 | .retryAfterSeconds |
status is the HTTP status — 0 when there was no response: the request
never left (validation) or never arrived (connection, token endpoint).
This table was once three rows shorter, and an integrator built a handler from it that ended in
if (e instanceof PushError). The two missing classes were the ones a broken deployment throws — wrong host and rejected credentials — and they did not extendPushErroreither, so both fell through to a silent 500. Hence both the completeness and the shared base class.
PlatformAuthError is still exported for callers who use createTokenProvider
directly; when the client fetches the token, it arrives wrapped as
PushTokenError so the base-class rule holds.
Auth and quota both answer 403 and have nothing in common as fixes, which is
why they are separate classes here. A quota 403 means the plan's monthly send
allowance is used up (with the workspace's overage policy on drop) or the
active-device ceiling is reached — not that your credentials are wrong.
PushAuthError.reason is read out of the token that was just sent, so a 403 says
which of the usual causes it is:
PushAuthError: Push API rejected the request (403): the token does not carry the
scope `push-notification:write` (it has: push-notification:read)Every method is async, including the ones whose local checks fail before any
request goes out — a method that sometimes throws synchronously and sometimes
rejects is a trap, because the .catch() written for the network misses it.
API
// applications
push.listApplications() / createApplication(input) / getApplication(id?)
push.updateApplication(input, id?) / deleteApplication(id?) / verifyApplication(id?)
// devices
push.registerDevice(input, id?) / unregisterDevice(token, id?) / listUserDevices(userRef, id?)
// sending
push.send(message, id?) / sendToUser(userRef, message, id?) / sendToUsers(refs, message, id?)
push.listMessages({ limit }, id?) / getMessage(messageId, id?) / cancelMessage(messageId, id?)
// templates
push.listTemplates(id?) / createTemplate(input, id?) / getTemplate(tid, id?)
push.updateTemplate(tid, input, id?) / deleteTemplate(tid, id?)
// categories & consent
push.listCategories(id?) / createCategory(input, id?) / updateCategory(key, input, id?)
push.deleteCategory(key, id?)
push.getPreferences(userRef, id?) / setPreference(userRef, input, id?)
push.unsubscribe(userRef, category?, id?) / forgetUser(userRef, id?)
// engagement & workspace
push.recordEvent(input, id?) / listSegments(id?) / analyticsOverview({ from, to }, id?)
push.getSettings() / updateSettings(input)
// several applications, one token
push.app('app_other').sendToUser(…)Every method takes an optional trailing applicationId that overrides the
client's default; push.app(id) binds one for a whole chain.
Client options
| Option | Type | Description |
|---|---|---|
| endpoint | string | The API gateway. Required. |
| workspaceId | string | Required. |
| auth | ClientCredentialsOptions | Client credentials; the SDK manages the token. Pass this or token. |
| token | string \| () => string \| Promise<string> | A token, or a function returning one. |
| applicationId | string | Default application for every call. |
| fetch | typeof fetch | Custom fetch — used for API calls and for the token request. |
| timeoutMs | number | Per-request timeout. Default 30 000; 0 disables. |
Troubleshooting
| Symptom | Cause |
|---|---|
| Every call 404s | endpoint points at a push. host. There isn't one — use the gateway. |
| PushConnectionError … does not resolve | The host name is wrong, or its DNS record is missing. The error names the host and the cause. |
| A poll loop returns a message full of zeroes | It stopped at processing. Poll until a terminal state — see above. |
| PushNotFoundError on a real application id | The id belongs to another workspace; it is scoped to the token's. |
| Works locally, 401 in production after minutes | A token string instead of auth. |
| A marketing send reached someone who opted out | A typo in category. An unknown key is accepted and behaves like no category. Compare against listCategories(). |
| Send succeeds, no notification arrives | Check getMessage(id) — targetedCount: 0 (no devices), suppressedCount (opted out), throttledCount (guardrail). |
| targetedCount: 0 for a user who definitely has the app | The device token never reached your backend, or userRef at registration differs from the one you send to. listUserDevices(userRef) settles it in one call. |
| One user stopped receiving anything, others are fine | A rotated device token that was never re-registered — onTokenRefresh is not wired up. |
| Everything failedCount at once | The application's Firebase credentials. Call verifyApplication(). |
| PushQuotaError on registerDevice | The active_devices ceiling — a gauge, enforced at registration. |
| iOS silent, Android fine | The application has apnsEnabled without valid .p8 credentials; verifyApplication() reports apns: "invalid". |
| Action buttons missing | More than four, or the iOS category was never registered in the app. |
Build & test
npm install
npm run build # tsup → dist/index.{js,cjs,d.ts}
npm test # vitest — 81 unit tests
npx vitest run --coverage # ≥ 98% line coverage on src/The suite covers URL and verb composition for every method, local validation, each error mapping (including quota-vs-auth on the shared 403), and the token provider end to end — caching, refresh skew, single-flight, invalidation, and every OAuth rejection.
