Quickstart
From zero to your first push in three steps. Total time: ~5 minutes if you already have a GitHub account.
-
1. Sign in and create an app
Sign in with GitHub, then create your first app from the onboarding form. The slug is what shows up in API errors and panel URLs — pick something short.
-
2. Generate an API key
In your app's panel, open the API keys tab and click Generate. The raw
np_…value is shown exactly once — copy it now and put it in a secrets manager. -
3. Send your first notification
The example below sends to every device registered against the app. Once you have real devices registered (see Web Push below), it'll fan out to all of them.
bash Copycurl -X POST https://nitroping.dev/api/v1/notifications \ -H "Authorization: ApiKey np_..." \ -H "Content-Type: application/json" \ -d '{ "title": "Welcome to nitroping", "body": "Your first push works.", "target": {"all": true} }'targetis required. Three shapes:{"all": true},{"device_ids": ["..."]}, or{"user_ids": ["..."]}. -
Prefer an SDK? Skip the cURL.
Seven official, open-source SDKs wrap all of this — install one instead of hand-rolling REST calls. Source for every language lives in github.com/productdevbook/nitroping-sdk. See Official SDKs for the full list and install commands.
bash Copy// npm i nitroping import { Nitroping } from "nitroping" const np = new Nitroping({ apiKey: "np_..." }) await np.notifications.send({ target: { all: true }, title: "Hello from the SDK", body: "Your first push, no cURL required.", })
REST API
Base URL: https://nitroping.dev/api/v1 (swap the host for a
self-hosted or staging deployment). All requests and responses are JSON.
Two kinds of API key.
Server-side calls use your secret
key: Authorization: ApiKey np_…
(Bearer np_… works too). It can do everything — send, read, manage devices and users.
Browser and mobile code uses your public
key: Authorization: Public pk_…. It's safe to ship in client
code and is limited to the public subset (device registration, inbox,
topics, preferences, conversions) under /api/v1/public/…. Never put an
np_
key in a
browser.
Prefer an interactive reference? A live, try-it-now API explorer is at /api/docs (Scalar UI), and the raw OpenAPI spec is served at /api/openapi.json.
/api/v1/devices
Register a device. Idempotent on (app_id, token, user_id)
— calling again with the same payload returns 201
with "created": false.
Optional tags
(array of short strings) labels the device for tag-based
segmentation — see
Tag segmentation
below.
Optional timezone
(IANA name, e.g. "Europe/Istanbul") lets the server
deliver in the device's local time — it powers quiet-hours
deferral. See
Delivery controls
below.
Request
POST /api/v1/devices HTTP/1.1
Host: nitroping.dev
Authorization: ApiKey np_...
Content-Type: application/json
{
"platform": "web",
"token": "https://fcm.googleapis.com/fcm/send/...",
"user_id": "user-1234",
"timezone": "Europe/Istanbul",
"web_push_p256dh": "BPq...",
"web_push_auth": "abc...",
"metadata": {"ua": "Mozilla/5.0 (...)"},
"tags": ["premium", "tr"]
}
Response — 201 Created
{
"id": "0193fcd6-3a8e-7f1c-9c4a-9a3a5b6c7d8e",
"created": true
}
/api/v1/devices/:id
Partial update. Only tags
is accepted today; pass []
to clear all tags. Returns 404
if the device does not belong to the authenticated app.
Request
PATCH /api/v1/devices/0193fcd6-3a8e-7f1c-9c4a-9a3a5b6c7d8e HTTP/1.1
Host: nitroping.dev
Authorization: ApiKey np_...
Content-Type: application/json
{
"tags": ["premium", "ios"]
}
Response — 200 OK
{
"id": "0193fcd6-3a8e-7f1c-9c4a-9a3a5b6c7d8e",
"tags": ["premium", "ios"]
}
/api/v1/notifications
Send (or schedule) a notification. target
is required and may be one of: {all: true}, {device_ids: [...]}, {user_ids: [...]},
{tags: [...]}
(any-match OR, see Tag segmentation),
{segment: {...}}
(rule-based, see Audience segments),
or {topic: "..."}
(everyone subscribed to a topic, see Topics).
Optional fields: data
(free-form JSON), icon, image, click_action,
deep_link
(URL opened on body tap — see
Deep links + actions
below), actions
(array of {id, title, icon?}
objects, max 3 for iOS), scheduled_at
(ISO 8601 UTC, future-dated; the row sits in status: "scheduled"
until a per-minute sweeper picks it up), recurrence
(a cron string for repeating sends — see Recurring send),
and email_to
(array of email addresses — the same notification is also delivered as
email, with title
as the subject and body
as the content, alongside push).
Request
POST /api/v1/notifications HTTP/1.1
Host: nitroping.dev
Authorization: ApiKey np_...
Content-Type: application/json
{
"title": "Order #4129 shipped",
"body": "Your package is on its way",
"deep_link": "https://example.com/orders/4129",
"actions": [
{"id": "track", "title": "Track shipment"},
{"id": "dismiss", "title": "Not now"}
],
"email_to": ["[email protected]"],
"target": {"all": true}
}
Response — 201 Created
{
"id": "0193fce0-1c4a-7f1c-9c4a-9a3a5b6c7d8e",
"status": "pending"
}
/api/v1/notifications
— scheduled send
Pass scheduled_at
(ISO 8601 timestamp with a timezone, UTC stored) to defer
delivery. The notification lands in status: "scheduled"
and a cron sweeper transitions it to pending
+ fans out at the requested time (resolution: one minute).
Past timestamps fire immediately.
Request
POST /api/v1/notifications HTTP/1.1
Host: nitroping.dev
Authorization: ApiKey np_...
Content-Type: application/json
{
"title": "Flash sale starts at 9am",
"body": "30% off everything for one hour.",
"target": {"tags": ["premium"]},
"scheduled_at": "2030-12-31T09:00:00Z"
}
/api/v1/notifications
— recurring send
Pass recurrence
(a 5-field cron string, e.g. "0 9 * * *"
for every day at 09:00) to turn a notification into a series. The series row stays in status: "scheduled"; on each cron tick the server clones a fresh
one-shot send and fans it out — the series itself is never
consumed.
-
recurrence_tz— IANA timezone the cron expression is evaluated in. Defaults toEtc/UTC. -
recurrence_until— optional ISO 8601 timestamp; no clones are created after it.
To stop a series, cancel it with DELETE /api/v1/notifications/:id — that halts future ticks. Already-dispatched clones are unaffected.
Request
curl -X POST https://nitroping.dev/api/v1/notifications \
-H "Authorization: ApiKey np_..." \
-H "Content-Type: application/json" \
-d '{
"title": "Daily digest",
"body": "Here is what happened today.",
"target": {"all": true},
"recurrence": "0 9 * * *",
"recurrence_tz": "Europe/Istanbul",
"recurrence_until": "2031-01-01T00:00:00Z"
}'
/api/v1/notifications/:id
Cancel a not-yet-finalised notification. Rows in status pending, scheduled, or
processing
transition to canceled; any matching Oban jobs
are cancelled so the provider isn't called.
-
200with{id, status: "canceled"}on success. -
404not_foundif the id doesn't exist for this app. -
409cannot_cancelwith a body referencing the current terminal state (e.g."Notification already in terminal state 'sent'").
Request
curl -X DELETE https://nitroping.dev/api/v1/notifications/0193fce0-1c4a-7f1c-9c4a-9a3a5b6c7d8e \
-H "Authorization: ApiKey np_..."
Tag segmentation
Each device carries an opaque tags
array (e.g. ["premium", "ios", "tr"]). Tags are
set when the device registers (POST /devices) or
later updated via PATCH. Notifications can target a tag set
with target: {tags: ["premium"]};
multiple tags use OR semantics (devices with any
listed tag qualify). AND-match is on the roadmap; today
combine tags client-side by sending one notification per
intersection.
Audience segments
For richer targeting than a flat tag list, target
also accepts a rule-based segment: target: {segment: {match, conditions}}.
Each condition is a {field, op, value}
triple; match
combines them — "all"
(the default) is AND, "any"
is OR.
-
field— one ofplatform,user_id,timezone,tag, ormetadata.<key>. -
op— one ofeq,neq,in,exists,contains,gt,lt. -
value— a scalar (or array forin).
Request
POST /api/v1/notifications HTTP/1.1
Host: nitroping.dev
Authorization: ApiKey np_...
Content-Type: application/json
{
"title": "Premium iOS users in Turkey",
"body": "A perk just for you.",
"target": {
"segment": {
"match": "all",
"conditions": [
{"field": "platform", "op": "eq", "value": "ios"},
{"field": "tag", "op": "in", "value": ["premium"]},
{"field": "timezone", "op": "eq", "value": "Europe/Istanbul"},
{"field": "metadata.plan", "op": "exists"}
]
}
}
}
Delivery controls
Two app-level controls (configured in the panel, not per-request)
shape when and how often a user is interrupted.
Both rely on the device's reported timezone / user_id.
Quiet hours
Set a quiet-hours window (start hour / end hour) in the panel. A
send that would land inside that window — evaluated in each device's
local timezone — is deferred to the end of the
window rather than dropped. Devices that never reported a timezone
fall back to the app default.
Per-user frequency cap
Set a daily per-user cap in the panel. When set, an end user
(keyed by user_id) receives at most N
notifications per day; sends beyond the cap are skipped for that
user. Other recipients in the same fan-out are unaffected.
/api/v1/track
Delivery-tracking callback fired by the SDK (or your service
worker). Best-effort: always returns 202, even if the
internal queue is briefly wedged.
Request
POST /api/v1/track HTTP/1.1
Host: nitroping.dev
Authorization: ApiKey np_...
Content-Type: application/json
{
"delivery_log_id": "0193fce0-9001-7f1c-9c4a-9a3a5b6c7d8e",
"event": "clicked"
}
Response — 202 Accepted
{
"accepted": true
}
Get notification status
After sending, poll the notification to watch delivery progress and read
engagement. Secret-key (np_…), read scope.
/api/v1/notifications/:id
Returns the full notification plus live counters
(targets / sent / delivered / failed / opened / clicked), per-variant
variant_stats
(when it's an A/B test), and a conversions
attribution summary.
{
"id": "0193fce0-1c4a-7f1c-9c4a-9a3a5b6c7d8e",
"status": "sent",
"title": "Your order shipped",
"body": "Track it in the app.",
"target_kind": "all",
"counters": {
"total_targets": 1240,
"total_sent": 1240,
"total_delivered": 1198,
"total_failed": 42,
"total_opened": 410,
"total_clicked": 120
},
"variant_stats": {},
"conversions": { "count": 18, "value_by_currency": { "USD": "892.40" } }
}
/api/v1/notifications
List your notifications newest-first. Query params: status, category,
limit
(≤100), and cursor
(pass the previous page's next_cursor
for keyset pagination).
A/B variants
Attach a variants
array to a send to test copy. Each variant carries its own title
+ body
and an optional integer weight
(default 1). Up to 6 variants. Each device is assigned a variant
deterministically
(a stable hash of the device id, biased by weight), so a device always
sees the same variant across re-sends.
curl https://nitroping.dev/api/v1/notifications \
-H "Authorization: ApiKey np_..." \
-H "Content-Type: application/json" \
-d '{
"title": "Weekend sale",
"body": "Default copy",
"target": { "all": true },
"variants": [
{ "title": "50% off this weekend", "body": "Shop now", "weight": 1 },
{ "title": "Half price — 2 days only", "body": "Don'\''t miss it", "weight": 1 }
]
}'
Read the results from
GET /notifications/:id
— the variant_stats
field breaks the funnel (sent → delivered → opened → clicked) down per
variant index.
Batch personalized send
/api/v1/notifications/batch
Render one shared template per recipient with their own variables, in a
single call (up to 1000 messages). Each message has its own vars
and target; top-level fields (data, category, …) apply to every message. Requires the Pro plan
and the send
scope. Partial success is allowed — one bad message doesn't sink the
batch.
Request
curl https://nitroping.dev/api/v1/notifications/batch \
-H "Authorization: ApiKey np_..." \
-H "Content-Type: application/json" \
-d '{
"template": "order-shipped",
"data": { "channel": "transactional" },
"messages": [
{ "vars": { "name": "Ada", "tracking": "1Z999" }, "target": { "user_ids": ["u1"] } },
{ "vars": { "name": "Linus", "tracking": "1Z888" }, "target": { "user_ids": ["u2"] } }
]
}'
Response — 200 OK
{
"created": ["0193fce0-...-aaaa", "0193fce0-...-bbbb"],
"errors": [],
"counts": { "created": 2, "failed": 0 }
}
Email, SMS & Slack channels
Alongside push, a send can fan out to extra channels by adding these optional fields on POST /api/v1/notifications:
email_to: array of email addresses.-
sms_to: array of E.164 phone numbers (e.g."+14155551234") — sent via Twilio. -
slack_webhooks: array of Slack incoming-webhook URLs (https://hooks.slack.com/…). -
fallback_email: a single address that's emailed only if the push fails for every targeted device (zero delivered). -
category: a label used for per-user opt-out (see Preferences).
curl https://nitroping.dev/api/v1/notifications \
-H "Authorization: ApiKey np_..." \
-H "Content-Type: application/json" \
-d '{
"title": "Payment received",
"body": "We got your payment — thanks!",
"category": "billing",
"target": { "user_ids": ["u1"] },
"email_to": ["[email protected]"],
"sms_to": ["+14155551234"],
"slack_webhooks": ["https://hooks.slack.com/services/T000/B000/XXXX"],
"fallback_email": "[email protected]"
}'
Workflows / journeys
A workflow is a multi-step journey you author in the panel — an ordered
list of send
and wait
steps a single end user is walked through over time (e.g. welcome →
wait 1 day → tips). You start a run per user from your backend.
/api/v1/workflows/:id/trigger
Secret-key (np_…), send
scope. Body: {"user_id": "..."}. Returns 202
with the run. At most one live run per (workflow, user): re-triggering an already-running journey
returns 409 already_running; a disabled workflow returns 403 workflow_disabled.
curl https://nitroping.dev/api/v1/workflows/WORKFLOW_ID/trigger \
-H "Authorization: ApiKey np_..." \
-H "Content-Type: application/json" \
-d '{ "user_id": "u1" }'
{
"run_id": "0193fce0-1c4a-7f1c-9c4a-9a3a5b6c7d8e",
"status": "running"
}
Topics
Topics let an end user follow a channel (a sports team, a product, a
thread) and receive every send to that topic. These are public
(pk_…) endpoints — safe to call from your client app — and
operate on a device_id
(returned by device registration).
-
POST /api/v1/public/topics/:topic/subscribe— body{device_id}→{topic, subscribed: true} -
POST /api/v1/public/topics/:topic/unsubscribe— body{device_id} -
GET /api/v1/public/topics?device_id=…—{topics: [...]} -
GET /api/v1/public/topics/:topic/count—{topic, subscribers}
Then send to everyone subscribed with target: {topic: "..."}
on POST /api/v1/notifications.
# Subscribe a device to a topic
curl https://nitroping.dev/api/v1/public/topics/launch-news/subscribe \
-H "Authorization: Public pk_..." \
-H "Content-Type: application/json" \
-d '{ "device_id": "DEVICE_ID" }'
# → { "topic": "launch-news", "subscribed": true }
Preferences / opt-out
Build a preference center: let users opt out of a notification category
(e.g. "marketing"). A send carrying an opted-out category
skips that user entirely — no push and no inbox entry. Public
(pk_…) endpoints, keyed by user_id.
-
GET /api/v1/public/preferences?user_id=…→{preferences: {category: opted_out}} -
POST /api/v1/public/preferences— body{user_id, category, opted_out}→{category, opted_out}
# Opt a user out of the "marketing" category
curl https://nitroping.dev/api/v1/public/preferences \
-H "Authorization: Public pk_..." \
-H "Content-Type: application/json" \
-d '{ "user_id": "u1", "category": "marketing", "opted_out": true }'
# → { "category": "marketing", "opted_out": true }
Conversions
/api/v1/public/conversions
Report a conversion (a purchase, a signup) to attribute revenue back to
the notification that drove it. Public (pk_…). Only event
is required; attach notification_id
to attribute it to a specific send. The attributed totals show up in
GET /notifications/:id
under conversions.
curl https://nitroping.dev/api/v1/public/conversions \
-H "Authorization: Public pk_..." \
-H "Content-Type: application/json" \
-d '{
"event": "purchase",
"notification_id": "0193fce0-1c4a-7f1c-9c4a-9a3a5b6c7d8e",
"user_id": "u1",
"value": 49.90,
"currency": "USD"
}'
# → { "id": "0193fce0-...-cccc", "event": "purchase" }
GDPR user erasure
/api/v1/users/:user_id
Erase everything held for one of your end users — their devices, inbox
items, topic subscriptions, preferences, and frequency counters — in a
single transaction. Secret-key (np_…), manage
scope. Returns the per-table delete counts.
DELETE /api/v1/users/u1
Authorization: ApiKey np_...
{
"user_id": "u1",
"erased": {
"devices": 3,
"inbox_items": 12,
"topic_subscriptions": 2,
"preferences": 1,
"frequency_counters": 4
}
}
API key scopes
Secret (np_…) keys can be restricted to a set of scopes
when you create them in the panel ("New key"). A request that needs a
scope the key doesn't hold gets 403 insufficient_scope.
send— create + cancel notifications, trigger workflows, batch send.read— list + get notifications.devices— register / update / delete devices.manage— GDPR user erasure.
A key created with no scopes has full access (back-compat for existing keys). Grant the narrowest set each integration needs.
In-app inbox
Every time a notification fans out to a user's devices, nitroping
also creates an inbox item
for that user — one per (notification, user_id), with a snapshot of the title,
body and data plus a read/unread flag. That gives you a durable
message centre even when a push is missed or dismissed.
The inbox endpoints are public: authenticate with
Authorization: Public pk_…
(the same publishable key used by the browser SDK) and scope every
call to a user_id.
/api/v1/public/inbox
List a user's inbox items, newest first. Query params: user_id
(required), unread_only
(true/false), and limit.
Returns {items: [...]}.
GET /api/v1/public/inbox/unread_count?user_id=…
returns just {unread_count}
— cheap enough to poll for a badge.
Request
# List a user's unread inbox items
curl "https://nitroping.dev/api/v1/public/inbox?user_id=user-1234&unread_only=true&limit=20" \
-H "Authorization: Public pk_..."
# Just the unread badge count
curl "https://nitroping.dev/api/v1/public/inbox/unread_count?user_id=user-1234" \
-H "Authorization: Public pk_..."
/api/v1/public/inbox/:id/read
Mark a single item read. Body is {user_id}; the updated item is returned.
POST /api/v1/public/inbox/read_all
(body {user_id}) marks every unread item
read and returns {marked_read}
(the count).
Request
# Mark a single item read
curl -X POST https://nitroping.dev/api/v1/public/inbox/0193fce0-1c4a-7f1c-9c4a-9a3a5b6c7d8e/read \
-H "Authorization: Public pk_..." \
-H "Content-Type: application/json" \
-d '{"user_id": "user-1234"}'
# Mark everything read
curl -X POST https://nitroping.dev/api/v1/public/inbox/read_all \
-H "Authorization: Public pk_..." \
-H "Content-Type: application/json" \
-d '{"user_id": "user-1234"}'
Web Push setup
Three files. A service worker that receives the push event, a page
that asks the user for permission and subscribes, and one fetch()
to register the subscription with nitroping.
Prerequisites: in your app's Credentials tab, the VAPID bundle must be configured. The panel can auto-generate it for you.
1. public/sw.js
Place this at the root of your public assets so the browser can
register it from /sw.js.
self.addEventListener("push", (event) => {
const data = event.data?.json() ?? {};
event.waitUntil(
self.registration.showNotification(data.title ?? "Notification", {
body: data.body,
icon: data.icon,
image: data.image,
// Action buttons (id + title + optional icon) — surfaced on the
// notification UI. The id comes back on `notificationclick` via
// `event.action` so you can branch on the chosen action.
actions: data.actions ?? [],
data: data.data,
})
);
});
self.addEventListener("notificationclick", (event) => {
const { notification_id, device_id, deep_link } = event.notification.data ?? {};
const action_id = event.action || null;
const url = deep_link || event.notification.data?.url || "/";
event.waitUntil(Promise.all([
// POST the open/click back so the panel counters tick + outbound
// webhooks fire. Best-effort: we still openWindow on transient
// network failure.
fetch("https://nitroping.dev/api/v1/events", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
notification_id,
device_id,
type: action_id ? "clicked" : "opened",
action_id,
}),
}).catch(() => {}),
event.notification.close(),
clients.openWindow(url),
]));
});
2. Subscribe + register
Run on a user gesture (button click). Permission prompts triggered outside a click handler are auto-denied on most browsers.
const VAPID_PUBLIC_KEY = "<paste-your-vapid-public-key>";
const NITROPING_PUBLIC_KEY = "pk_...";
async function subscribeToPush() {
const registration = await navigator.serviceWorker.register("/sw.js");
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
await fetch("https://nitroping.dev/api/v1/public/devices", {
method: "POST",
headers: {
"Authorization": `Public ${NITROPING_PUBLIC_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
platform: "web",
token: subscription.endpoint,
web_push_p256dh: bufToBase64Url(subscription.getKey("p256dh")),
web_push_auth: bufToBase64Url(subscription.getKey("auth")),
}),
});
}
// VAPID keys are base64url; PushManager wants a Uint8Array.
function urlBase64ToUint8Array(b64) {
const pad = "=".repeat((4 - (b64.length % 4)) % 4);
const base64 = (b64 + pad).replace(/-/g, "+").replace(/_/g, "/");
return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
}
function bufToBase64Url(buf) {
return btoa(String.fromCharCode(...new Uint8Array(buf)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
3. Test send
From your terminal, with the API key from the panel:
curl -X POST https://nitroping.dev/api/v1/notifications \
-H "Authorization: ApiKey np_..." \
-H "Content-Type: application/json" \
-d '{
"title": "Welcome to nitroping",
"body": "Your first push works.",
"target": {"all": true}
}'
Drop-in UI widgets
The JS SDK ships two ready-made browser components from nitroping/widgets
— a web-push opt-in prompt and an in-app
inbox bell. They render with plain DOM (no framework, no build step
required), inject one scoped stylesheet, accept theme overrides, and
authenticate with your public pk_
key.
Push prompt
Auto-hides when push is unsupported or the user already decided; runs the
full subscribe-and-register flow on click. Still needs /sw.js.
import { mountPushPrompt } from "nitroping/widgets";
mountPushPrompt({
target: "#push-prompt",
publicKey: "pk_...",
appId: "<your-app-uuid>",
userId: "user-42",
// theme: { accent: "#16a34a" },
onSubscribe: ({ device }) => console.log("subscribed", device.id),
});
Inbox bell
A bell with an unread badge + dropdown. Polls the unread count,
lazy-loads the list on open, marks items read on click. The handle
exposes refresh() and unmount().
import { mountInboxBell } from "nitroping/widgets";
const bell = mountInboxBell({
target: "#inbox-bell",
publicKey: "pk_...",
userId: "user-42",
pollIntervalMs: 30000,
onItemClick: (item) => {
// return false to suppress the default deep-link navigation
},
});
// bell.refresh(); // force a re-poll
// bell.unmount(); // detach timers + remove from the DOM
FCM (Firebase) setup
To send to Android, nitroping needs a Firebase service account — the full JSON key file, not an FCM server key or sender id.
1. Get the service account JSON from Firebase
- Open the Firebase Console and pick your project.
- ⚙️ (gear) → Project settings → Service accounts.
-
Click Generate new private key
→ Generate key. A
.jsonfile downloads. -
Open it and copy the whole contents (the object starting with
{"type": "service_account", …}).
Keep this file secret — it contains a private key. nitroping stores it encrypted at rest and never shows it again.
2. Add it in the panel
- Go to your team's Credentials vault (the app's Credentials tab links to it).
- Under FCM (Google), create a new bundle.
- Fill Project ID (your Firebase project id) and paste the full JSON into Service account JSON.
- Save. One service account serves every app on the team that points at the same Firebase project.
3. Bind it to the app
In the app's Credentials tab, pick the bundle from the FCM (Google) dropdown and save. Android sends now authenticate with that service account.
Deep links + actions
Two optional fields on POST /api/v1/notifications steer what happens when the user interacts with a notification:
-
deep_link— a URL fired when the user taps the body of the notification. -
actions— an array of{id, title, icon?}objects rendered as buttons next to the notification. Max 3 entries on iOS; web push renders all of them; Android forwards the list as data and your service handles the UI.
Web push
deep_link
opens in clients.openWindow(...)
on body tap. actions[]
renders as buttons in showNotification. Click events
are POSTed to /api/v1/events
from the service worker — the
sw.js snippet
above already wires this up; no per-notification work on your side.
iOS (APNs)
deep_link
lands in the custom payload at data.deep_link. Your
app reads it from UNNotificationContent.userInfo
and routes via UIApplication.shared.open(...)
or a SwiftUI router.
actions[]
is shipped as a stringified JSON payload at data.actions_json. To render system action buttons, your app registers a
UNNotificationCategory
whose identifier matches the one nitroping sets on the alert.
Auto-registering categories from the server side isn't possible
— the system pulls them from
UNUserNotificationCenter.current().setNotificationCategories(...)
on app launch.
import UIKit
import UserNotifications
final class NotificationRouter: NSObject, UNUserNotificationCenterDelegate {
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let userInfo = response.notification.request.content.userInfo
// `deep_link` is the body-tap URL — open it directly.
if response.actionIdentifier == UNNotificationDefaultActionIdentifier,
let raw = userInfo["deep_link"] as? String,
let url = URL(string: raw) {
UIApplication.shared.open(url)
}
// `actions_json` carries the per-action list as a JSON string.
// The chosen action's id is in `response.actionIdentifier`.
if let actionsJSON = userInfo["actions_json"] as? String,
let data = actionsJSON.data(using: .utf8),
let actions = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] {
let chosenId = response.actionIdentifier
if let action = actions.first(where: { $0["id"] as? String == chosenId }) {
// Route to your action handler — e.g. "reply", "track", etc.
print("user picked action: \(action["title"] ?? "?")")
}
}
completionHandler()
}
}
Android (FCM)
deep_link
lands at data.deep_link. Your
FirebaseMessagingService.onMessageReceived
(or the Activity
launched via a PendingIntent) reads it and routes
via Intent(Intent.ACTION_VIEW, Uri.parse(...)). actions[]
lands at data.actions_json
for you to deserialize and pin as NotificationCompat.Builder.addAction(...)
on the notification you build locally.
import android.app.PendingIntent
import android.content.Intent
import android.net.Uri
import androidx.core.app.NotificationCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import org.json.JSONArray
class NitropingMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(message: RemoteMessage) {
val data = message.data
// Body tap → open `deep_link` via ACTION_VIEW.
val deepLink = data["deep_link"]
val tapIntent = deepLink
?.let { Intent(Intent.ACTION_VIEW, Uri.parse(it)) }
?.apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK }
val tapPI = tapIntent?.let {
PendingIntent.getActivity(
this, 0, it,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
}
val builder = NotificationCompat.Builder(this, "default")
.setContentTitle(data["title"])
.setContentText(data["body"])
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setAutoCancel(true)
if (tapPI != null) builder.setContentIntent(tapPI)
// `actions_json` carries the action button list.
data["actions_json"]?.let { raw ->
val arr = JSONArray(raw)
for (i in 0 until arr.length()) {
val a = arr.getJSONObject(i)
val id = a.getString("id")
val title = a.getString("title")
val actionIntent = Intent(this, NotificationActionReceiver::class.java)
.putExtra("action_id", id)
.putExtra("deep_link", deepLink)
val pi = PendingIntent.getBroadcast(
this, id.hashCode(), actionIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
builder.addAction(0, title, pi)
}
}
// notify(...) is intentionally elided for brevity.
}
}
Errors & rate limits
Error format
Every error response is JSON with the same shape — an error
object carrying a stable code, a human message, and sometimes details:
{
"error": {
"code": "validation_failed",
"message": "target must be one of: {all} | {device_ids} | {user_ids} | {tags}",
"details": { "title": ["can't be blank"] }
}
}
Common codes: invalid_api_key
(401), insufficient_scope
(403), app_disabled
(403), not_found
(404), validation_failed
(422), payment_required
(402, a feature needs a higher plan), quota_exceeded
(429), and idempotency_conflict
(409).
Rate limits
Requests are rate-limited per source IP, per secret key (on the
high-volume send route), and per app (on the public pk_
surface). Over the limit returns 429
— back off and retry. For sends, an Idempotency-Key
header makes a retry safe (a repeat with the same key + body replays the
original response instead of sending twice).
Framework quickstarts
Each block is the subscribe-and-register flow flavored for one
framework. Pair it with the sw.js
from the Web Push
section.
Next.js (App Router)
Mark the component "use client". The service worker
itself goes in public/sw.js — Next won't bundle it.
"use client";
import { useEffect, useState } from "react";
const VAPID_PUBLIC_KEY = "<paste-your-vapid-public-key>";
const NITROPING_PUBLIC_KEY = "pk_...";
export function EnablePush() {
const [enabled, setEnabled] = useState(false);
async function enable() {
const reg = await navigator.serviceWorker.register("/sw.js");
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
await fetch("https://nitroping.dev/api/v1/public/devices", {
method: "POST",
headers: {
"Authorization": `Public ${NITROPING_PUBLIC_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
platform: "web",
token: sub.endpoint,
web_push_p256dh: bufToBase64Url(sub.getKey("p256dh")),
web_push_auth: bufToBase64Url(sub.getKey("auth")),
}),
});
setEnabled(true);
}
return (
<button onClick={enable} disabled={enabled}>
{enabled ? "Push enabled" : "Enable push notifications"}
</button>
);
}
function urlBase64ToUint8Array(b64: string) {
const pad = "=".repeat((4 - (b64.length % 4)) % 4);
const base64 = (b64 + pad).replace(/-/g, "+").replace(/_/g, "/");
return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
}
function bufToBase64Url(buf: ArrayBuffer | null) {
if (!buf) return "";
return btoa(String.fromCharCode(...new Uint8Array(buf)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
Vue 3 + Vite
Drop sw.js
into public/. Vite serves public/*
at the site root in dev and build.
<script setup lang="ts">
import { ref } from "vue";
const VAPID_PUBLIC_KEY = "<paste-your-vapid-public-key>";
const NITROPING_PUBLIC_KEY = "pk_...";
const enabled = ref(false);
async function enable() {
const reg = await navigator.serviceWorker.register("/sw.js");
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
await fetch("https://nitroping.dev/api/v1/public/devices", {
method: "POST",
headers: {
"Authorization": `Public ${NITROPING_PUBLIC_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
platform: "web",
token: sub.endpoint,
web_push_p256dh: bufToBase64Url(sub.getKey("p256dh")),
web_push_auth: bufToBase64Url(sub.getKey("auth")),
}),
});
enabled.value = true;
}
function urlBase64ToUint8Array(b64: string) {
const pad = "=".repeat((4 - (b64.length % 4)) % 4);
const base64 = (b64 + pad).replace(/-/g, "+").replace(/_/g, "/");
return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
}
function bufToBase64Url(buf: ArrayBuffer | null) {
if (!buf) return "";
return btoa(String.fromCharCode(...new Uint8Array(buf)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
</script>
<template>
<button @click="enable" :disabled="enabled">
{{ enabled ? "Push enabled" : "Enable push notifications" }}
</button>
</template>
Vanilla HTML + JS
No build step. Drop into any static page.
<!doctype html>
<html>
<body>
<button id="enable">Enable push notifications</button>
<script>
const VAPID_PUBLIC_KEY = "<paste-your-vapid-public-key>";
const NITROPING_PUBLIC_KEY = "pk_...";
document.getElementById("enable").addEventListener("click", async () => {
const reg = await navigator.serviceWorker.register("/sw.js");
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
await fetch("https://nitroping.dev/api/v1/public/devices", {
method: "POST",
headers: {
"Authorization": "Public " + NITROPING_PUBLIC_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
platform: "web",
token: sub.endpoint,
web_push_p256dh: bufToBase64Url(sub.getKey("p256dh")),
web_push_auth: bufToBase64Url(sub.getKey("auth")),
}),
});
});
function urlBase64ToUint8Array(b64) {
const pad = "=".repeat((4 - (b64.length % 4)) % 4);
const base64 = (b64 + pad).replace(/-/g, "+").replace(/_/g, "/");
return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
}
function bufToBase64Url(buf) {
return btoa(String.fromCharCode(...new Uint8Array(buf)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
</script>
</body>
</html>
Official SDKs
Seven first-party, open-source SDKs wrap device registration, sends and engagement tracking so you don't have to hand-roll the REST calls. All target the same API described above — drop down to POST /devices / POST /notifications whenever you need something an SDK doesn't expose yet.
Source for every language lives in one monorepo: github.com/productdevbook/nitroping-sdk. Each card below links straight to its package directory.
JavaScript / TypeScript
npm i nitroping
— server + browser client for sends, devices and the inbox, plus
nitroping/widgets
drop-in UI.
React Native
npm i nitroping-react-native
— auto-reports device timezone
+ iOS environment, ships Expo + Firebase adapters and an inbox
client, with opt-in debug
logging.
Swift
Add via Swift Package Manager — APNs token retrieval, device registration and click tracking.
View on GitHubGo
go get github.com/productdevbook/nitroping-sdk/go
— typed client for the REST API.
Kotlin
Maven / Gradle, group dev.nitroping
— wraps FirebaseMessaging registration + engagement events.