Docs

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

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": ["..."]}.

REST API

Base URL: https://nitroping.dev/api/v1. All requests are JSON. Authenticate with Authorization: ApiKey np_… (or Bearer np_… — same effect).

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.

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",
  "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). 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).

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"}
  ],
  "target": {"all": true}
}

Response — 201 Created

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

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
}

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}
  }'

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>

Mobile SDKs

First-party iOS + Android SDKs are in progress. Until they ship, you can use the REST API directly from your existing APNs / FCM integration — register the device with POST /devices and send via POST /notifications.

iOS

Coming soon

Swift package with APNs token retrieval, device registration, and click-tracking helpers.

Android

Coming soon

Kotlin library that wraps FirebaseMessaging registration and forwards open/click events to our track endpoint.

Follow progress on GitHub .