Idempotency
How Subscriby guarantees that retrying a write request never double-creates, double-mutates, or double-charges.
Every POST, PATCH, PUT, and DELETE request against /v1/* requires an Idempotency-Key header. The key makes retries safe: replaying with the same key returns the cached response, while replaying the same key with a different body is rejected.
Header contract
POST /v1/webhook-endpoints HTTP/1.1
Authorization: Bearer sbt_live_...
Idempotency-Key: 0199a4ae-6c02-7b5c-9f51-c5f0da5e2f12
Content-Type: application/json- Opaque string, required on every mutation.
- Any format works — generate a UUID v4 per call.
- Omit the header and Subscriby returns
IDEMPOTENCY_KEY_MISSING(HTTP 400).
Replay semantics
| Condition | Server response | Reply header |
|---|---|---|
| First time this key is seen | Fresh response, normal HTTP status | — |
| Same key, same request body hash | Cached response, same HTTP status | Idempotent-Replay: true |
| Same key, different request body hash | 409 IDEMPOTENCY_KEY_REUSED | — |
| Same key, concurrent in-flight | 425 IDEMPOTENCY_REPLAY_IN_PROGRESS | — |
Successful 2xx JSON responses are cached under sha256(token_id:method:path:key) for 24 hours, with the response body encrypted at rest — the one-time secrets in a token mint or a webhook endpoint registration never sit in the cache in clear text. After that, the same key rotates out of cache and behaves as a fresh request. 4xx and 5xx responses are not cached — callers are free to retry them.
Request body fingerprint
Reuse detection hashes the full raw request body (SHA-256). Two requests are "the same" when their token, method, path, key, and body all match byte-for-byte. That's stricter than Stripe's fingerprinting model and keeps accidental reuse obvious.
Client-side recipe
A safe retry loop:
$key = (string) Str::uuid();
$response = Http::withHeaders([
'Authorization' => "Bearer {$token}",
'Idempotency-Key' => $key,
'Content-Type' => 'application/json',
])->retry(
times: 3,
sleepMilliseconds: 500,
when: fn ($exception, $request) => $exception?->response?->status() >= 500,
)->post('https://api.subscriby.net/v1/webhook-endpoints', [
'name' => 'Zapier hook',
'url' => 'https://hooks.zapier.com/...',
'events' => ['subscription.created'],
]);If the first attempt succeeds and the second attempt's retry fires anyway (e.g. your client timed out on a 200 it never saw), the second call hits the cache and returns the same response — you never double-register the endpoint.
Cached replays carry Idempotent-Replay: true so your integration can
recognise them and skip any client-side side effects that might otherwise fire
twice.
What doesn't require a key
GETrequests — naturally idempotent.- Public routes such as
/.well-known/ai-plugin.json,/abilities.json,/openapi.json, and/v1/ping. - Webhook deliveries from Subscriby → your endpoint (those carry their own event ID for consumer-side de-duplication).
Related
How is this guide?