Idempotency

Idempotency makes a write safe to retry. If your worker crashes, a queue retries, or a network call times out after Roundtrip already accepted the write, sending the same idempotency key prevents a duplicate operation.

Use it on every write that might be retried:

  • creating a notification
  • replacing the main content or a named slot
  • appending history
  • sending a follow-up push
  • canceling a notification

SDK shortcut

With a notification handle, pass the semantic part as idempotency. The SDK adds the notification id, operation, and slot for you.

replace-decision.ts
await notification.content.replace(
  "decision",
  {
    content: {
      title: "Approve rollback?",
      status: "warning",
    },
  },
  {
    idempotency: incident.decisionVersion,
  }
);

That becomes a key scoped like:

Text
ntf_123:replace:decision:7

Use a stable business value. Good choices are a domain revision, queue job id, event id, step sequence, or milestone name.

Initial sends

For the first notification, there is no notification id yet. Use your domain id and operation name.

open-incident.ts
const notification = await roundtrip.notify({
  channel: "on-call",
  push: { title: "Checkout latency is high" },
  content: { title: "Checkout latency", status: "warning" },
  metadata: { incidentId: incident.id },
  idempotency: ["incident", incident.id, "open"],
});

Common writes

updates.ts
await notification.content.append(
  {
    content: {
      title: "Database checked",
      status: "success",
    },
  },
  { idempotency: "database-checked" }
);

await notification.content.replace(
  "progress",
  {
    content: {
      title: "Import progress",
      description: `${step.done}/${step.total} rows processed`,
      status: "info",
    },
  },
  { idempotency: step.sequence }
);

await notification.push(
  {
    title: "Rollback decision needed",
    priority: "high",
  },
  { idempotency: "rollback-decision" }
);

await notification.cancel({ idempotency: "incident-closed" });

Explicit keys

Use idempotencyKey when you already have the exact key, or when calling the HTTP API directly.

curl
curl -X POST https://api.roundtrip.sh/api/v1/notifications/ntf_123/content/append \
  -H "Authorization: Bearer ak_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "content": { "title": "Database checked", "status": "success" },
    "idempotencyKey": "incident:inc_42:append:database-checked"
  }'

The SDK still exposes the raw helper:

manual-key.ts
const key = notification.idempotency.replace(
  "decision",
  incident.decisionVersion
);

What not to use

Do not use a random UUID, timestamp, or attempt number for a retryable write. Those values change on retry, so Roundtrip cannot recognize the duplicate.

For append history, use a key that names the specific timeline entry. Reusing the same append key for different history entries will dedupe the later write.

Mental model

WriteSDK optionScoped as
Create["incident", id, "open"]incident:<id>:open
Replace main"status-v7"ntf_123:replace:main:status-v7
Replace slotdecisionVersionntf_123:replace:decision:<version>
Append"database-checked"ntf_123:append:database-checked
Push"rollback-decision"ntf_123:push:rollback-decision
Cancel"incident-closed"ntf_123:cancel:incident-closed

The key only needs to be stable for the write you want to dedupe.