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.

workflow.ts
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

StepSDK callWhat changesPush?
Createroundtrip.notify(...)Creates the durable notificationYes, unless push.enabled: false
Replacenotification.content.replace(...)Updates current UINo
Replace slotnotification.content.replace("decision", ...)Updates one named sectionNo
Appendnotification.content.append(...)Adds historyNo
Pushnotification.push(...)Sends follow-up lock-screen copyYes
Cancelnotification.cancel()Marks the notification canceledNo

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.

open-incident.ts
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:

investigate.ts
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.

request-decision.ts
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.

webhook.ts
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:

close-incident.ts
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.

job-workflow.ts
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"],
});
job-worker.ts
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",
    });
  }
}
job-webhook.ts
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:

typed-actions.ts
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>.

refund-workflow.ts
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",
      },
    });
  });
webhook.ts
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.

  1. Create a domain record first, such as incidents.id or jobs.id.
  2. Send the first notification with metadata containing that domain id.
  3. Store notification.id on the domain record.
  4. Put every Roundtrip write behind a queue or worker with an idempotency value.
  5. On webhooks, verify the signature, persist the event, then enqueue domain work.
  6. Use the notification as a projection of domain state, not the source of truth.
  7. 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.
worker-message.ts
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.

Next steps