Notification workflows
A Roundtrip workflow is one notification that keeps changing as your backend learns more. The notification has a current snapshot, named slots, append-only history, follow-up pushes, and webhook-driven continuation.
For complex apps, start with a typed workflow object. It keeps step and action ids in one place, creates namespaced action ids for the card, and routes webhook events back into typed handlers.
import { RoundtripClient } from "@roundtrip/sdk";
export const roundtrip = new RoundtripClient({
apiKey: process.env.ROUNDTRIP_API_KEY!,
baseUrl: "https://api.roundtrip.sh",
});
export const incidentWorkflow = roundtrip.webhooks.workflow({
name: "incident",
actions: {
decision: ["rollback", "wait"] as const,
},
load: async (event) => {
return getIncident(event.data.metadata.incidentId);
},
});Now incidentWorkflow.actions("decision", …) and
incidentWorkflow.onAction("rollback", …) are checked by TypeScript. A typo
like "rollbak" fails before it reaches production.
Mental model
| Step | SDK call | What changes | Push? |
|---|---|---|---|
| Create | roundtrip.notify(...) | Creates the durable notification | Yes, unless push.enabled: false |
| Replace | notification.content.replace(...) | Updates current UI | No |
| Replace slot | notification.content.replace("decision", ...) | Updates one named section | No |
| Append | notification.content.append(...) | Adds history | No |
| Push | notification.push(...) | Sends follow-up lock-screen copy | Yes |
| Cancel | notification.cancel() | Marks the notification canceled | No |
Incident workflow
This is the full flow: open incident, append findings, replace the current summary, add a decision slot, send a follow-up push, handle the response, then close the incident.
const incidentId = "inc_42";
const notification = await roundtrip.notify({
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",
},
metadata: { incidentId },
idempotency: ["incident", incidentId, "open"],
});
await saveIncidentNotificationId(incidentId, notification.id);Append investigation findings as they happen:
const notification = roundtrip.notifications.fromId(incident.notificationId);
await notification.content.append(
{
content: {
title: "Database checked",
description: "No saturation found.",
status: "success",
},
},
{ idempotency: "database-checked" }
);
await notification.content.replace(
{
title: "Checkout latency",
description: "Cache miss rate is elevated. Mitigation in progress.",
status: "warning",
},
{ idempotency: incident.revision }
);Add a named decision slot. The actions come from the typed workflow definition, not hardcoded strings inside the card.
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" },
wait: { label: "Keep investigating", style: "secondary" },
}),
},
response: {
mode: "required",
behavior: "resolve",
webhook: "https://api.example.com/roundtrip/webhooks",
},
metadata: {
incidentId: incident.id,
decisionVersion: incident.decisionVersion,
},
},
{ idempotency: incident.decisionVersion }
);
await notification.push(
{
title: "Rollback decision needed",
body: "A rollback is ready for approval.",
priority: "high",
},
{ idempotency: "rollback-decision" }
);Handle the response with the same workflow object. The context includes the
verified event, data, loaded incident state, typed step, and typed action.
incidentWorkflow
.onResponse(async ({ data, state }) => {
await saveRoundtripResponse({
incidentId: state.id,
notificationId: data.notificationId,
action: data.action,
targetSlot: data.targetSlot,
targetRevision: data.targetRevision,
userId: data.userId,
});
})
.onAction("decision", "rollback", async ({ state }) => {
await enqueueRollback(state.id);
})
.onAction("decision", "wait", async ({ state }) => {
await markDecisionDeferred(state.id);
});
export const POST = roundtrip.webhooks
.router()
.use(incidentWorkflow)
.handler({ secret: process.env.ROUNDTRIP_WEBHOOK_SECRET! });After the rollback worker runs, update the same notification:
const notification = roundtrip.notifications.fromId(incident.notificationId);
await notification.content.append({
content: {
title: "Rollback approved",
description: "Rollback started by on-call.",
status: "success",
},
});
await notification.content.replace("decision", {
content: {
title: "Rollback approved",
description: "Decision recorded.",
status: "success",
},
});
await notification.content.replace({
title: "Checkout latency resolved",
description: "p95 returned to baseline after rollback.",
status: "success",
});Long-running job workflow
For jobs, keep one slot as the current progress and append sparse milestones. If the worker needs input, replace a decision slot and send a follow-up push.
const jobWorkflow = roundtrip.webhooks.workflow({
name: "customer-import",
actions: {
operator: ["retry", "cancel"] as const,
},
load: async (event) => getImportJob(event.data.metadata.jobId),
});
const notification = await roundtrip.notify({
channel: "jobs",
push: { title: "Import started", body: "Customer import #4821" },
content: {
title: "Customer import #4821",
description: "Queued.",
status: "info",
},
metadata: { jobId: "job_4821" },
idempotency: ["job", "job_4821", "open"],
});for await (const step of runImport(job.id)) {
await notification.content.replace(
"progress",
{
content: {
title: "Import progress",
description: `${step.done}/${step.total} rows processed`,
status: step.failed ? "error" : "info",
},
metadata: { jobId: job.id, sequence: step.sequence },
},
{ idempotency: step.sequence }
);
if (step.milestone) {
await notification.content.append({
content: {
title: step.milestone,
description: step.summary,
status: "success",
},
});
}
if (step.needsOperatorDecision) {
await notification.content.replace("operator", {
content: {
title: "Import needs attention",
description: step.errorMessage,
status: "warning",
actions: jobWorkflow.actions("operator", {
retry: { label: "Retry", style: "primary" },
cancel: { label: "Cancel import", style: "destructive" },
}),
},
response: {
mode: "required",
behavior: "continue",
webhook: "https://api.example.com/roundtrip/webhooks",
},
metadata: { jobId: job.id, sequence: step.sequence },
});
await notification.push({
title: "Import needs attention",
body: "Choose whether to retry or cancel.",
priority: "high",
});
}
}jobWorkflow
.onAction("operator", "retry", async ({ state }) => {
await retryImport(state.id);
})
.onAction("operator", "cancel", async ({ state }) => {
await cancelImport(state.id);
});Why type the workflow?
The webhook payload still contains strings, because action ids are part of the public API and need to be stable. The SDK reduces string drift by making the workflow definition the source of truth:
const flow = roundtrip.webhooks.workflow({
name: "deploy",
actions: {
approval: ["deploy", "hold"] as const,
},
});
flow.actions("approval", {
deploy: { label: "Deploy", style: "primary" },
hold: { label: "Hold", style: "destructive" },
});
flow.onAction("approval", "deploy", async (ctx) => {
ctx.step; // "approval"
ctx.action; // "deploy"
});The encoded button id is still stable on the wire:
rtwf:deploy:approval:deploy.
For curl or legacy senders, the same router can also route a raw action like
"deploy" when the workflow action map has exactly one matching step, or when
the response includes targetSlot. Use generated ids for multi-step screens
where the same action name could appear in more than one place.
Fluent action handlers
For backend routes, the cleanest shape is often to define the workflow once,
then chain typed action handlers. The workflow can parse metadata and form
values before your handler runs, so handlers do not have to cast
Record<string, unknown>.
function refundMetadata(value: unknown) {
if (
value &&
typeof value === "object" &&
"refundId" in value &&
typeof value.refundId === "string" &&
"orderId" in value &&
typeof value.orderId === "string"
) {
return { refundId: value.refundId, orderId: value.orderId };
}
throw new Error("Invalid refund metadata");
}
function requestInfoValues(value: unknown) {
if (
value &&
typeof value === "object" &&
"note" in value &&
typeof value.note === "string"
) {
return { note: value.note };
}
throw new Error("Invalid request-info values");
}
export const refundReview = roundtrip.webhooks
.workflow({
name: "refund-review",
actions: {
review: ["approve", "deny", "request-info"] as const,
},
metadata: refundMetadata,
values: {
"request-info": requestInfoValues,
},
})
.onAction("approve", async ({ metadata, notification }) => {
await approveRefund(metadata.refundId);
await notification.content.replace({
title: "Refund approved",
status: "success",
});
})
.onAction("deny", async ({ metadata, notification }) => {
await denyRefund(metadata.refundId);
await notification.content.replace({
title: "Refund denied",
status: "error",
});
})
.onAction("request-info", async ({ metadata, values, notification }) => {
await requestMoreInfo(metadata.orderId, values.note);
await notification.content.append({
content: {
title: "More info requested",
description: values.note,
status: "info",
},
});
});export const POST = roundtrip.webhooks
.router()
.use(refundReview)
.handler({ secret: process.env.ROUNDTRIP_WEBHOOK_SECRET! });refundReview.onAction("approve", …) works because the workflow has one step. If
an action name appears in more than one step, use
refundReview.onAction("review", "approve", …) to disambiguate.
Backend worker pattern
Treat Roundtrip as the notification and response edge, not your workflow database.
- Create a domain record first, such as
incidents.idorjobs.id. - Send the first notification with
metadatacontaining that domain id. - Store
notification.idon the domain record. - Put every Roundtrip write behind a queue or worker with an idempotency value.
- On webhooks, verify the signature, persist the event, then enqueue domain work.
- Use the notification as a projection of domain state, not the source of truth.
- Make retries boring: every create, replace, append, push, and cancel should have a stable idempotency value. For SDK handle writes, pass the domain revision or event id and let the SDK add notification id, operation, and slot.
await queue.publish({
type: "roundtrip.content.replace",
notificationId: incident.roundtripNotificationId,
slot: "decision",
idempotency: incident.decisionVersion,
});App Store-safe UI
Roundtrip screens are declarative UI, not remote native code. Remote workflow content can render cards, blocks, actions, forms, files, and links. Native capabilities such as camera, photos, files, contacts, and location must be first-party Roundtrip features: user-initiated, permissioned, and reviewed in the app binary. For example, an image upload block is fine when Roundtrip owns the camera/photo picker and the workflow receives only the resulting attachment metadata.
Use in-app declarative screens for trusted workflows where staying in context is valuable. Link out to the web for untrusted third-party HTML, arbitrary embedded apps, payments, or anything that needs browser isolation.