This package is part of the nitroping-sdk monorepo.
The npm package name (nitroping) is unchanged. See the top-level README for SDKs in other languages.
nitroping-js
Zero-dependency TypeScript SDK for nitroping.
Send push notifications, register devices, verify webhooks. Pure ESM, works in Node, Bun, Deno, Cloudflare Workers, and browsers.
Why nitroping?
nitroping is a hosted push notification service that
unifies APNs (iOS), FCM (Android), and Web Push behind one API. Send to a
single device, a user across all of their devices, or every device in your app
with one HTTP call. The service handles fanout, retries, idempotency, quota
and outbound webhooks for delivery state — you write the product, not the
plumbing.
nitroping-js is the official TypeScript client. It has zero runtime
dependencies, ships as native ESM with full type definitions, and runs
anywhere modern JavaScript runs: Node 18+, Bun, Deno, Cloudflare Workers,
Vercel Edge, and the browser. The whole bundle is small enough to drop into
serverless without thinking about it.
Install
npm install nitroping
pnpm add nitroping
bun add nitroping
yarn add nitroping
Quick Start
Send a notification (server)
import { Nitroping } from "nitroping"
const np = new Nitroping({ apiKey: process.env.NITROPING_API_KEY! })
const result = await np.notifications.send(
{
title: "Order #4129 shipped",
body: "Your package is on its way.",
deepLink: "https://example.com/orders/4129",
actions: [
{ id: "track", title: "Track" },
{ id: "view", title: "View order" },
],
target: { userIds: ["user-42"] },
},
{ idempotencyKey: "order-shipped-4129" },
)
console.log(result.id, result.status)
Register a Web Push device (browser)
import { subscribeWebPush } from "nitroping/web"
const { device } = await subscribeWebPush({
publicKey: "pk_live_...",
appId: "0e1d2c3b-4a59-6877-9876-543210abcdef",
userId: "user-42",
})
console.log("Subscribed device", device.id)
Then drop a tiny /public/sw.js that handles push:
self.addEventListener("push", (event) => {
const data = event.data?.json() ?? {}
event.waitUntil(
self.registration.showNotification(data.title ?? "Notification", {
body: data.body,
icon: data.icon,
data: { deepLink: data.deep_link },
}),
)
})
self.addEventListener("notificationclick", (event) => {
event.notification.close()
const url = event.notification.data?.deepLink ?? "/"
event.waitUntil(self.clients.openWindow(url))
})
Verify a webhook (server)
import { verifyWebhook } from "nitroping/webhooks"
export async function POST(request: Request) {
const body = await request.text()
const signature = request.headers.get("x-nitroping-signature")
try {
const event = await verifyWebhook({
body,
signature,
secret: process.env.NITROPING_WEBHOOK_SECRET!,
})
if (event.type === "notification.delivered") {
console.log("delivered", event.data["notification_id"])
}
} catch (err) {
return new Response("signature error", { status: 400 })
}
return new Response("ok")
}
Tree shaking
Three independent entry points — import only what you need:
import { Nitroping } from "nitroping"
import { subscribeWebPush } from "nitroping/web"
import { verifyWebhook } from "nitroping/webhooks"
The web and webhooks modules don't pull the HTTP client in, so a server
that only verifies webhooks doesn't ship any request code, and a browser app
that only subscribes doesn't ship anything secret-key-flavored.
API reference
new Nitroping(options)
Creates a server-side client.
const np = new Nitroping({
apiKey: "np_live_...",
baseUrl: "https://nitroping.dev",
timeoutMs: 30_000,
})
np.notifications.send(input, options?)
Sends a notification. Returns { id, status }. Throws NitropingError on
non-2xx with the server's code, message, and per-field details.
await np.notifications.send(
{
title: "Welcome!",
body: "Glad to have you on board.",
icon: "https://example.com/icon.png",
image: "https://example.com/hero.png",
deepLink: "https://example.com/welcome",
data: { onboarding: true },
actions: [{ id: "tour", title: "Take the tour" }],
target: { all: true },
},
{ idempotencyKey: "welcome-user-42" },
)
target is a discriminated union — exactly one of:
{ all: true } | Broadcast to every active device |
{ deviceIds: [...] } | Hit specific device rows |
{ userIds: [...] } | Hit every device row a user owns |
np.notifications.get(id)
Fetch a previously-enqueued notification by id. Returns the full row
(with counters: total_sent, total_delivered, total_failed, etc).
const n = await np.notifications.get("abc-123")
console.log(n["counters"])
np.devices.register(input)
Register a device with the secret API key. Use this for iOS / Android
where you control the server. Returns { id, created } — created is
false when an existing row matched on (token, user_id).
await np.devices.register({
platform: "ios",
token: deviceToken,
userId: "user-42",
metadata: { app_version: "2.4.1" },
})
np.devices.deactivate(id)
Sets status = inactive on the device row. Subsequent sends skip it.
await np.devices.deactivate("device-id")
subscribeWebPush(options) — nitroping/web
Browser-only. Registers a service worker, asks for permission, fetches the
VAPID public key, calls pushManager.subscribe, and registers the resulting
endpoint with nitroping — all in one call.
import { subscribeWebPush } from "nitroping/web"
const { device, subscription } = await subscribeWebPush({
publicKey: "pk_live_...",
appId: "uuid-of-the-app",
serviceWorkerPath: "/sw.js",
serviceWorkerScope: "/",
userId: "user-42",
})
Idempotent — call on every page load; the server dedupes on
(app_id, token).
verifyWebhook(options) — nitroping/webhooks
Verifies the X-Nitroping-Signature header and returns the parsed event.
import { verifyWebhook } from "nitroping/webhooks"
const event = await verifyWebhook({
body: rawString,
signature: request.headers.get("x-nitroping-signature"),
secret: process.env.NITROPING_WEBHOOK_SECRET!,
tolerance: 300,
})
The signing scheme is HMAC-SHA256 over "<unix>.<raw body>". The header
ships as t=<unix>, v1=<hex> — same as Polar / Stripe. Use the raw
request body string (not a re-serialized parsed object) or the HMAC won't
match.
Framework recipes
Express / Fastify webhook handler
import express from "express"
import { verifyWebhook } from "nitroping/webhooks"
const app = express()
app.post(
"/webhooks/nitroping",
express.raw({ type: "application/json" }),
async (req, res) => {
try {
const event = await verifyWebhook({
body: req.body.toString("utf8"),
signature: req.header("x-nitroping-signature"),
secret: process.env.NITROPING_WEBHOOK_SECRET!,
})
res.status(200).send("ok")
} catch {
res.status(400).send("bad signature")
}
},
)
Hono / Cloudflare Workers
import { Hono } from "hono"
import { Nitroping } from "nitroping"
import { verifyWebhook } from "nitroping/webhooks"
interface Env {
NITROPING_API_KEY: string
NITROPING_WEBHOOK_SECRET: string
}
const app = new Hono<{ Bindings: Env }>()
app.post("/send", async (c) => {
const np = new Nitroping({ apiKey: c.env.NITROPING_API_KEY })
const result = await np.notifications.send({
title: "Hello from Workers",
body: "Running on the edge",
target: { all: true },
})
return c.json(result)
})
app.post("/webhooks", async (c) => {
const event = await verifyWebhook({
body: await c.req.text(),
signature: c.req.header("x-nitroping-signature"),
secret: c.env.NITROPING_WEBHOOK_SECRET,
})
return c.json({ received: event.id })
})
export default app
Next.js App Router
import { Nitroping } from "nitroping"
export const runtime = "edge"
export async function POST(request: Request) {
const np = new Nitroping({ apiKey: process.env.NITROPING_API_KEY! })
const { title, body } = await request.json()
const result = await np.notifications.send({
title,
body,
target: { all: true },
})
return Response.json(result)
}
import { verifyWebhook } from "nitroping/webhooks"
export async function POST(request: Request) {
const body = await request.text()
const event = await verifyWebhook({
body,
signature: request.headers.get("x-nitroping-signature"),
secret: process.env.NITROPING_WEBHOOK_SECRET!,
})
return Response.json({ ok: true, type: event.type })
}
Errors
Every error thrown by the SDK extends NitropingError. Narrow by instanceof
to handle specific cases:
NitropingError | Base class. Any non-2xx response, or any internal failure with no more specific subclass. |
NetworkError | fetch rejected (DNS, TLS, offline, abort). Original cause attached via cause. |
InvalidSignatureError | verifyWebhook HMAC mismatch, missing header, malformed header. |
TimestampOutOfRangeError | verifyWebhook signature valid but t= outside the tolerance window. |
WebPushUnsupportedError | subscribeWebPush running where Service Worker / Push API isn't available. |
PermissionDeniedError | subscribeWebPush and the user (or browser policy) blocks notifications. |
import { Nitroping, NitropingError, NetworkError } from "nitroping"
try {
await np.notifications.send({ title: "Hi", body: "There", target: { all: true } })
} catch (err) {
if (err instanceof NetworkError) {
} else if (err instanceof NitropingError && err.code === "quota_exceeded") {
console.log(err.details)
} else {
throw err
}
}
TypeScript
Type declarations ship in the package — no separate @types/... install
needed. The SDK targets ESNext with strict mode and avoids any in the
public surface. All public types (SendNotificationRequest,
NotificationTarget, WebhookEvent, etc.) are exported from the main
entry.
Runtime support
| Node 18 + | Yes |
| Bun 1.0 + | Yes |
| Deno 1.30 + | Yes |
| Cloudflare Workers | Yes |
| Vercel Edge Runtime | Yes |
| Modern browsers | Yes (nitroping/web + nitroping/webhooks) |
nitroping (the server SDK) is also usable in the browser, but you should
not ship the secret np_ key — use nitroping/web with a public pk_
key instead.
License
MIT — Copyright (c) 2026 productdevbook.
Built by @productdevbook — nitroping.dev · OSS core