Idempotency
Retry any write safely with the Idempotency-Key header.
Every POST / PATCH / DELETE under /v1/ honours the Idempotency-Key header, following draft-ietf-httpapi-idempotency-key-header.
How it works
- Send
Idempotency-Key: <opaque>. Recommended generator: UUID v4, one per logical operation. - The first final response (any 2xx or 4xx) is stored against the key, scoped to your tenant, for 24 hours.
- A replay within that window — the duplicate
POSTyour client sends after a network timeout — returns the stored response byte-for-byte withIdempotency-Replayed: true. No second side-effect. - The same key with a different request body (or method/path) returns
409with problemtypeidempotency-key-reuse. - A replay while the first request is still running waits briefly for it to finish and replays its response; if it has not finished you get
409with problemtypeidempotency-in-progress— back off and retry with the same key. - 5xx responses are not stored. The key is released so your retry re-executes the request instead of replaying the outage.
curl -X POST https://app.tourical.com/api/v1/clients \
-H "Authorization: Bearer tk_live_..." \
-H "Idempotency-Key: 4c1b1a8e-3f0c-4d0f-9d8b-6d2b7e0a1f11" \
-H "Content-Type: application/json" \
-d '{ "email": "ana@example.com", "firstName": "Ana" }'The wire format matches Stripe's contract closely: if you already retry-with-idempotency-key against Stripe, the work to support Tourical is changing the URL and the header name.
Retry pattern
async function createClientOnce(input: ClientInput, key = crypto.randomUUID()) {
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch("https://app.tourical.com/api/v1/clients", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"Idempotency-Key": key, // same key on every attempt
},
body: JSON.stringify(input),
});
if (res.status < 500 && res.status !== 429) return res; // final (stored) answer
await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
}
throw new Error("gave up");
}What is retry-safe without a key?
- All
GETendpoints. DELETE /v1/webhooks/{id}— a second delete returns404.POST /v1/webhooks/test— retrying sends another test delivery, by design.POST /v1/webhooks/{id}/deliveries/{deliveryId}/redeliver— each call queues one more delivery of the same event; your consumer dedupes onTourical-Event-Id.

