Documentation

Build push in minutes

Wire nitroping into your stack. Copy-pasteable snippets for iOS, Android, Web Push and three browser frameworks — plus seven official SDKs. No build step required.

On this page

Quickstart

From zero to your first push in three steps. Total time: ~5 minutes if you already have a GitHub account.

  1. 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. 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. 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 Copy
    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}
      }'

    target is required. Three shapes: {"all": true}, {"device_ids": ["..."]}, or {"user_ids": ["..."]}.

  4. 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.

POST /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

json Copy
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

json Copy
{
  "id": "0193fcd6-3a8e-7f1c-9c4a-9a3a5b6c7d8e",
  "created": true
}
PATCH /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

json Copy
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

json Copy
{
  "id": "0193fcd6-3a8e-7f1c-9c4a-9a3a5b6c7d8e",
  "tags": ["premium", "ios"]
}
POST /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

json Copy
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

json Copy
{
  "id": "0193fce0-1c4a-7f1c-9c4a-9a3a5b6c7d8e",
  "status": "pending"
}
POST /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

json Copy
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"
}
POST /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 to Etc/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

bash Copy
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"
  }'
DELETE /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.

  • 200 with {id, status: "canceled"} on success.
  • 404 not_found if the id doesn't exist for this app.
  • 409 cannot_cancel with a body referencing the current terminal state (e.g. "Notification already in terminal state 'sent'").

Request

bash Copy
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 of platform, user_id, timezone, tag, or metadata.<key>.
  • op — one of eq, neq, in, exists, contains, gt, lt.
  • value — a scalar (or array for in).

Request

json Copy
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.

POST /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

json Copy
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

json Copy
{
  "accepted": true
}

Get notification status

After sending, poll the notification to watch delivery progress and read engagement. Secret-key (np_…), read scope.

GET /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.

json Copy
{
  "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" } }
}
GET /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.

json Copy
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

POST /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

json Copy
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

json Copy
{
  "created": ["0193fce0-...-aaaa", "0193fce0-...-bbbb"],
  "errors": [],
  "counts": { "created": 2, "failed": 0 }
}

Email, SMS &amp; 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).
json Copy
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.

POST /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.

bash Copy
curl https://nitroping.dev/api/v1/workflows/WORKFLOW_ID/trigger \
  -H "Authorization: ApiKey np_..." \
  -H "Content-Type: application/json" \
  -d '{ "user_id": "u1" }'
json Copy
{
  "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.

bash Copy
# 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}
bash Copy
# 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

POST /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.

bash Copy
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

DELETE /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.

json Copy
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.

GET /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

bash Copy
# 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_..."
POST /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

bash Copy
# 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.

js Copy
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.

js Copy
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:

bash Copy
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.

js Copy
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().

js Copy
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

  1. Open the Firebase Console and pick your project.
  2. ⚙️ (gear) → Project settingsService accounts.
  3. Click Generate new private keyGenerate key. A .json file downloads.
  4. 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

  1. Go to your team's Credentials vault (the app's Credentials tab links to it).
  2. Under FCM (Google), create a new bundle.
  3. Fill Project ID (your Firebase project id) and paste the full JSON into Service account JSON.
  4. 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.

Errors &amp; 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:

json Copy
{
  "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.

tsx Copy
"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.

vue Copy
<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.

html Copy
<!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.

View on GitHub

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.

View on GitHub

Swift

Add via Swift Package Manager — APNs token retrieval, device registration and click tracking.

View on GitHub

Python

pip install nitroping — server-side client for sends and device management.

View on GitHub

Go

go get github.com/productdevbook/nitroping-sdk/go — typed client for the REST API.

View on GitHub

Kotlin

Maven / Gradle, group dev.nitroping — wraps FirebaseMessaging registration + engagement events.

View on GitHub

PHP

Install with Composer — server-side client for sends and devices.

View on GitHub
Connection lost — reconnecting…