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 -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:
{
"ok": true,
"notifications": [{ "channel": "deploys", "id": "ntf_1", "deviceCount": 3 }],
"errors": []
}The same thing with the 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 -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 -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:
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 -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 -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:
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.
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 -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.pdfForms live in detail.layout as declarative blocks. Submitted values arrive in
the same signed webhook event as action taps.
{
"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
pushdeliveryoptionalLock-screen copy. A replace or append does not notify anyone unless you
explicitly call /push or include push copy in an append.
replacecurrent stateoptionalUpdates the main card or one named slot.
appendhistoryoptionalAdds chronological timeline entries.
idempotencySDK optionoptionalPut 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.