Requesting approvals

Approvals are just notifications with actions and a response config. The publish call returns immediately; the decision arrives later as a signed webhook event.

One thing to swap

Replace ak_xxx with a workspace API key and use a channel that already exists in your workspace.

Send the approval

curl
curl -X POST https://api.roundtrip.sh/api/v1/notifications \
  -H "Authorization: Bearer ak_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "ops",
    "push": {
      "title": "Deploy api@1.4.2 to production?",
      "priority": "high"
    },
    "content": {
      "title": "Deploy api@1.4.2 to production?",
      "description": "12 commits since the last release.",
      "actions": [
        { "id": "deploy", "label": "Deploy", "style": "primary" },
        { "id": "hold", "label": "Hold", "style": "destructive" }
      ]
    },
    "response": {
      "mode": "required",
      "behavior": "resolve",
      "webhook": "https://api.example.com/roundtrip/webhooks"
    },
    "metadata": { "deploymentId": "deploy_8f21" }
  }'
SDK
const deployWorkflow = roundtrip.webhooks.workflow({
  name: "deploy",
  actions: {
    decision: ["deploy", "hold"] as const,
  },
});

const notification = await roundtrip.notify({
  channel: "ops",
  push: {
    title: "Deploy api@1.4.2 to production?",
    priority: "high",
  },
  content: {
    title: "Deploy api@1.4.2 to production?",
    description: "12 commits since the last release.",
    actions: deployWorkflow.actions("decision", {
      deploy: { label: "Deploy", style: "primary" },
      hold: { label: "Hold", style: "destructive" },
    }),
  },
  response: {
    mode: "required",
    behavior: "resolve",
    webhook: "https://api.example.com/roundtrip/webhooks",
  },
  metadata: { deploymentId: "deploy_8f21" },
});

console.log(notification.id);

The send call does not block

notify returns when the notification is created and push delivery has been queued. It does not wait for the person. Correlate the later webhook by notificationId and your own metadata.

Receive the decision

Roundtrip sends a signed event envelope:

POST to your webhook
{
  "id": "evt_rsp_1",
  "type": "response.submitted",
  "apiVersion": "2026-06-21",
  "createdAt": "2026-06-21T18:04:11.000Z",
  "data": {
    "notificationId": "ntf_1",
    "channel": "ops",
    "userId": "user_42",
    "kind": "action",
    "action": "deploy",
    "metadata": { "deploymentId": "deploy_8f21" },
    "submittedAt": "2026-06-21T18:04:11.000Z"
  }
}

The signature is in Roundtrip-Signature, with the form t=<unix_seconds>,v1=<hex>. Verify the raw body before parsing JSON. The workflow router accepts generated action ids from the SDK and raw curl action ids when the action map has one clear match.

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

const router = webhooks
  .workflow({
    name: "deploy",
    actions: {
      decision: ["deploy", "hold"] as const,
    },
  })
  .onResponse(async ({ data }) => {
    await saveDecision(data.notificationId, data.action);
  })
  .onAction("decision", "deploy", async ({ data }) => {
    await deploy(data.metadata.deploymentId);
  })
  .onAction("decision", "hold", async ({ data }) => {
    await holdDeploy(data.metadata.deploymentId);
  });

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 });
}

Named decision slots

Use a named slot when the approval is part of a longer workflow. The slot can be replaced as the recommendation changes, while old revisions and submissions stay auditable.

decision-slot.ts
const incidentWorkflow = roundtrip.webhooks.workflow({
  name: "incident",
  actions: {
    decision: ["rollback", "wait"] 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" },
        wait: { label: "Wait", style: "secondary" },
      }),
    },
    response: {
      mode: "required",
      behavior: "resolve",
      webhook: "https://api.example.com/roundtrip/webhooks",
    },
  },
  { idempotency: "rollback-decision" }
);

Webhook callbacks for slots include targetSlot and targetRevision, so a late submission can be understood against the exact UI the person saw.

Next steps