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": ["..."]}.
REST API
Base URL: https://nitroping.dev/api/v1. All requests are
JSON. Authenticate with Authorization: ApiKey np_…
(or Bearer np_…
— same effect).
/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
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
{
"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).
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
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
{
"id": "0193fce0-1c4a-7f1c-9c4a-9a3a5b6c7d8e",
"status": "queued"
}
/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/: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.
/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
}
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}
}'
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.
}
}
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>
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 soonSwift package with APNs token retrieval, device registration, and click-tracking helpers.
Android
Coming soonKotlin library that wraps FirebaseMessaging registration and forwards open/click events to our track endpoint.
Follow progress on GitHub .