Sending push notifications

push is the interruptive delivery layer. It is separate from durable content, so you can update the in-app UI without buzzing anyone, and send a follow-up push only when attention is needed.

One thing to swap

The API lives at https://api.roundtrip.sh. Replace ak_xxx with a workspace-scoped API key. Every channel you target must already exist in the workspace.

The minimum

A channel and a push.title are enough:

curl -X POST https://api.roundtrip.sh/api/v1/notifications \
  -H "Authorization: Bearer ak_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "deploys",
    "push": { "title": "Build #1283 passed" }
  }'

Channels decide who is notified

You publish to channels, not devices. The HTTP body accepts either a single channel or a channels array:

curl -X POST https://api.roundtrip.sh/api/v1/notifications \
  -H "Authorization: Bearer ak_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "channels": ["deploys", "alerts"],
    "push": { "title": "Nightly job finished" }
  }'

The 200 body has one notifications entry per channel that resolved, and unknown channels land in errors without failing the rest:

200 OK
{
  "ok": true,
  "notifications": [
    { "channel": "deploys", "id": "ntf_1", "deviceCount": 3 },
    { "channel": "alerts", "id": "ntf_2", "deviceCount": 5 }
  ],
  "errors": []
}

Push fields

titlestringrequired

The lock-screen title. The only required push field.

priority"min" | "low" | "normal" | "high" | "critical"optional

How hard the push tries to break through. Named or numeric 1-5. Defaults to normal.

bodystringoptional

The lock-screen body. Set this independently from content.description when the push should be shorter than the card it opens.

soundbooleanoptional

Play a sound. Defaults to true; set false for a silent delivery.

badgebooleanoptional

Update the app icon badge. Defaults to true.

enabledbooleanoptional

When false, no push is sent. The notification still lands in the in-app feed.

Priority

Priority maps to ntfy-style levels 1-5. Pass the name or the number.

NameNumberBehavior
min1Quietest. No sound, easy to miss by design.
low2Low-key.
normal3The default if you omit priority.
high4Prominent; surfaces above routine notifications.
critical5Bypasses quiet hours / Do Not Disturb.
curl
curl -X POST https://api.roundtrip.sh/api/v1/notifications \
  -H "Authorization: Bearer ak_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "alerts",
    "push": { "title": "Disk at 96% on db-primary", "priority": "critical" }
  }'

Custom push copy

Use push for short lock-screen copy and content for the durable card:

curl
curl -X POST https://api.roundtrip.sh/api/v1/notifications \
  -H "Authorization: Bearer ak_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "billing",
    "push": {
      "title": "New invoice to review",
      "body": "Acme Corp - $48,200"
    },
    "content": {
      "title": "Invoice #2042 - Acme Corp",
      "description": "Net-30, due 2026-07-20. Tap to review line items."
    }
  }'

Quiet and silent deliveries

Two quiet modes:

  • Silent push: set push.sound to false.
  • No push at all: set push.enabled to false; the notification still appears in the feed.
curl
curl -X POST https://api.roundtrip.sh/api/v1/notifications \
  -H "Authorization: Bearer ak_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "audit-log",
    "push": { "title": "Config synced (no changes)", "enabled": false }
  }'

Avoid duplicates

Put an idempotencyKey on every HTTP write. For initial sends, derive it from your source event id:

curl
curl -X POST https://api.roundtrip.sh/api/v1/notifications \
  -H "Authorization: Bearer ak_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "deploys",
    "push": { "title": "Build #1283 passed" },
    "idempotencyKey": "build-1283-passed"
  }'

For SDK workflows, use the shorter idempotency option on notification handles. The SDK adds the notification id, operation, and slot:

SDK
await notification.content.replace("decision", decisionUpdate, {
  idempotency: incident.decisionVersion,
});

See Idempotency for the full retry model.

Follow-up pushes

replace and append update durable UI. They do not send push unless you call the push endpoint explicitly:

curl
curl -X POST https://api.roundtrip.sh/api/v1/notifications/ntf_1/push \
  -H "Authorization: Bearer ak_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "push": {
      "title": "Rollback decision needed",
      "body": "A rollback is ready for approval.",
      "priority": "high"
    },
    "idempotencyKey": "incident-42:rollback-push"
  }'

The mobile push payload carries notificationId and, for update-specific pushes, an updateId. Tapping opens the existing notification instead of creating a new feed item.

Next steps