Creating endpoints require an Idempotency-Key header. Not optional, not ignored if absent: a
create without one is a 400 IDEMPOTENCY_KEY_REQUIRED.
curl -X POST https://stippa.nl/api/v1/appointments \
-H "Authorization: Bearer $STIPPA_KEY" \
-H "Idempotency-Key: booking-8f42c1a0-2026-08-01" \
-H "Content-Type: application/json" \
-d '{ "serviceId": "...", "startTime": "2026-08-14T09:00:00Z", "customer": { "email": "..." } }'
Why it is required rather than offered
A connection can drop after we have created the appointment and before you have read the response. Your client cannot tell that apart from a request that never arrived, so it retries, and the customer now has two appointments and, depending on the business, two confirmation emails and two charges.
Making the header optional means the integrations that most need it are the ones that skip it. The difference between an integrator with retry safety and one who discovers they needed it after double-booking somebody is that we refused the request.
Which endpoints
| Endpoint | Header |
|---|---|
POST /api/v1/appointments | Required |
POST /api/v1/customers | Required |
POST /api/v1/webhooks | Accepted and ignored |
PATCH on any resource | Accepted and ignored |
POST /api/v1/appointments/{id}/status | Accepted and ignored |
PATCH and the status transition are naturally idempotent: setting a status to cancelled
twice is one cancellation, and the second call is answered by the lifecycle rather than by
creating anything.
POST /api/v1/webhooks is the deliberate exception among the creates: it never reads the
header, so sending one does no harm and buys nothing. Zapier and tools like it send no idempotency
header at all, and requiring one would make the REST-hook subscription flow impossible. A
uniqueness constraint on the endpoint URL does the same job there instead: registering the same
URL twice is a 409 naming url, never a second subscription.
The three outcomes
Retrying with the same key produces exactly one of these.
Same key, same body, first time: the work happens
A normal 201. Nothing distinguishes it from a create without a key.
Same key, same body, already finished: the stored response comes back
The same status and the same body as the original, plus an Idempotency-Replayed: true header.
That header is how you tell a replay from a fresh create without diffing ids. This is the case
you are protected by.
Same key, same body, still running: 409 IDEMPOTENCY_IN_PROGRESS
Two of your retries overlapped. Wait a second and retry the same key; do not mint a new one.
And one outcome that is a bug rather than a retry:
Same key, different body: 409 IDEMPOTENCY_KEY_REUSED. Reporting it is deliberate. Silently
replaying the first response would hide a client bug and hand you a record for a request you did
not make. The key covers the method and the path as well as the body, so reusing one key across
POST /appointments and POST /customers is also a reuse conflict.
Only a success is stored
A key is consumed by a 2xx and by nothing else. When the create is refused (a 4xx) or fails
(a 5xx), the key is released and nothing is stored, so retrying with the same key runs the
request again rather than replaying the error. That is safe, because a refused or failed create
made nothing. It also means a correction is allowed: fix the body after a 400 or a 422 and
send it under the same key.
A stored response is kept for 24 hours and then pruned by a nightly pass. Plan your retries inside that window. A key sent again after it has been pruned is a new create.
The body is hashed as raw bytes, not as a canonicalised object. Two JSON documents differing only in key order or whitespace hash differently and count as different bodies. Serialise the retry from the same buffer rather than re-encoding your object.
Picking a key
Up to 255 characters. What makes a good one is that it is stable across your retries and unique across your creates.
Good. A uuid generated once, before the first attempt, and held for the whole retry
sequence. Or a deterministic key derived from something in your own system that identifies this
create uniquely, such as booking-{yourOrderId}.
Bad. A uuid generated inside the retry loop, which gives every attempt a fresh key and protects nothing. A timestamp, which collides under concurrency and changes between retries. Anything derived from the request body, since a body you corrected between attempts should be a new create and a body you did not correct already hashes the same.
// The key is minted once, outside the loop. That is the whole point.
const idempotencyKey = crypto.randomUUID();
for (let attempt = 0; attempt < 3; attempt++) {
const response = await fetch('https://stippa.nl/api/v1/appointments', {
method: 'POST',
headers: {
Authorization: `Bearer ${key}`,
'Idempotency-Key': idempotencyKey,
'Content-Type': 'application/json',
},
body,
});
if (response.ok) return response.json();
if (response.status === 409) {
// `code` sits at the top level of the body, beside the `error` string.
const { code } = await response.json();
// Still running: wait for the first attempt rather than starting a second.
if (code === 'IDEMPOTENCY_IN_PROGRESS') {
await new Promise((r) => setTimeout(r, 1000));
continue;
}
}
if (response.status < 500) throw new Error(`${response.status} ${await response.text()}`);
await new Promise((r) => setTimeout(r, 2 ** attempt * 500));
}
Keys are per business
Two businesses can use the same key string without colliding: the record is keyed on the business as well as the key, and the business comes from the credential. You do not need to namespace keys per tenant.