npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@conapps/push-sdk

v0.1.1

Published

TypeScript SDK for the platform Push Notification API — register devices, send notifications, manage categories and consent.

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-sdk

Server-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-notification for you. Point endpoint at 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         └──────────────┘
  1. The OS hands your app a token (Firebase getToken(), APNs didRegisterForRemoteNotificationsWithDeviceToken).
  2. 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.
  3. 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 userRef from the session, never from the request body. A client that may name its own userRef can 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_kit does 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             failed

queued 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, irreversible

A 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, and suppressedCount: 0 reads 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 chunk

scheduledFor 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 extend PushError either, 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.