Quickstart

Start with curl. Add the SDK when you want typed handles, idempotency helpers, and webhook routing.

Before you start

You need a Roundtrip workspace, at least one channel, and an API key that starts with ak_. The examples use https://api.roundtrip.sh; replace ak_xxx with your key.

1. Send a notification

The smallest notification is a channel and a push.title:

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

Save the returned id:

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

The same thing with the SDK:

SDK
import { RoundtripClient } from "@roundtrip/sdk";

const roundtrip = new RoundtripClient({
  apiKey: process.env.ROUNDTRIP_API_KEY!,
  baseUrl: "https://api.roundtrip.sh",
});

const notification = await roundtrip.notify({
  channel: "deploys",
  push: { title: "Build #1283 passed" },
});

console.log(notification.id);

2. Add durable content

push is delivery copy. content is the in-app UI Roundtrip keeps around.

curl
curl -X POST https://api.roundtrip.sh/api/v1/notifications \
  -H "Authorization: Bearer ak_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "on-call",
    "push": {
      "title": "Checkout latency is high",
      "body": "p95 crossed 2.4s",
      "priority": "high"
    },
    "content": {
      "title": "Checkout latency",
      "description": "Investigating elevated p95 latency.",
      "status": "warning"
    }
  }'

3. Append progress

Appending adds chronological history. It does not send another push unless you explicitly include one.

curl
curl -X POST https://api.roundtrip.sh/api/v1/notifications/ntf_1/content/append \
  -H "Authorization: Bearer ak_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "content": {
      "title": "Database checked",
      "description": "No saturation found.",
      "status": "success"
    },
    "idempotencyKey": "incident-42:db-checked"
  }'

SDK handles make this less stringy:

SDK
await notification.content.append(
  {
    content: {
      title: "Database checked",
      description: "No saturation found.",
      status: "success",
    },
  },
  { idempotency: "db-checked" }
);

4. Replace a decision slot

Replacing updates the current UI. A named slot is a durable section under the main card, useful for decisions that may change while the history remains auditable.

curl
curl -X POST https://api.roundtrip.sh/api/v1/notifications/ntf_1/content/replace \
  -H "Authorization: Bearer ak_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "slot": "decision",
    "content": {
      "title": "Approve rollback?",
      "description": "Rollback candidate is ready.",
      "status": "warning",
      "actions": [
        { "id": "rollback", "label": "Rollback", "style": "destructive" },
        { "id": "continue", "label": "Keep investigating", "style": "secondary" }
      ]
    },
    "response": {
      "mode": "required",
      "behavior": "resolve",
      "webhook": "https://api.example.com/roundtrip/webhooks"
    },
    "idempotencyKey": "incident-42:decision"
  }'

Then send a follow-up push only when attention is needed:

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

SDK version:

SDK
const incidentWorkflow = roundtrip.webhooks.workflow({
  name: "incident",
  actions: {
    decision: ["rollback", "continue"] as const,
  },
});

await notification.content.replace(
  "decision",
  {
    content: {
      title: "Approve rollback?",
      description: "Rollback candidate is ready.",
      status: "warning",
      actions: incidentWorkflow.actions("decision", {
        rollback: { label: "Rollback", style: "destructive" },
        continue: { label: "Keep investigating" },
      }),
    },
    response: {
      mode: "required",
      behavior: "resolve",
      webhook: "https://api.example.com/roundtrip/webhooks",
    },
  },
  { idempotency: "rollback-decision" }
);

await notification.push(
  {
    title: "Rollback decision needed",
    body: "A rollback is ready for approval.",
    priority: "high",
  },
  { idempotency: "rollback-push" }
);

5. Verify webhooks

Roundtrip sends signed event envelopes. Capture the raw request body, verify the Roundtrip-Signature header, then route with a typed workflow object.

Webhook handler
import { webhooks } from "@roundtrip/sdk";

const router = webhooks
  .workflow({
    name: "incident",
    actions: {
      decision: ["rollback", "continue"] as const,
    },
  })
  .onResponse(async ({ data }) => {
    console.log(data.notificationId, data.targetSlot);
  })
  .onAction("decision", "rollback", async ({ data }) => {
    await startRollback(data.metadata.incidentId);
  })
  .onAction("decision", "continue", async ({ data }) => {
    await keepInvestigating(data.metadata.incidentId);
  });

export async function POST(request: Request) {
  await router.handle({
    rawBody: await request.text(),
    headers: request.headers,
    secret: process.env.ROUNDTRIP_WEBHOOK_SECRET!,
  });

  return new Response(null, { status: 204 });
}

6. Upload files or submit forms

Files are uploaded separately, then referenced by id in the notification:

curl
curl -X POST "https://api.roundtrip.sh/api/v1/files?filename=quote.pdf" \
  -H "Authorization: Bearer ak_xxx" \
  -H "Content-Type: application/pdf" \
  --data-binary @quote.pdf

Forms live in detail.layout as declarative blocks. Submitted values arrive in the same signed webhook event as action taps.

form block
{
  "type": "form",
  "id": "refund-review",
  "fields": [
    {
      "type": "select",
      "name": "decision",
      "label": "Decision",
      "required": true,
      "options": [
        { "label": "Approve", "value": "approve" },
        { "label": "Deny", "value": "deny" }
      ]
    }
  ],
  "submit": { "label": "Submit", "action": "submit-refund", "style": "primary" }
}

What to remember

pushdeliveryoptional

Lock-screen copy. A replace or append does not notify anyone unless you explicitly call /push or include push copy in an append.

replacecurrent stateoptional

Updates the main card or one named slot.

appendhistoryoptional

Adds chronological timeline entries.

idempotencySDK optionoptional

Put one on every retryable SDK write. Notification handles scope it from the notification id, operation, and slot for you. Use idempotencyKey for curl or exact raw keys. See Idempotency.