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 | Not accepted |
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. Zapier and tools like it
send no idempotency header at all, and refusing them 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 cannot produce two subscriptions.
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.
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) {
const { error } = await response.json();
// Still running: wait for the first attempt rather than starting a second.
if (error.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.