Skip to content
For the first ten businesses: six months of Stippa free, in exchange for your feedback.Find out more
Stippa
FeaturesPricingDemoHelpContact
Log inStart free

Getting started

  • Introduction
  • Authentication
  • Permissions

Core concepts

  • Errors
  • Pagination
  • Idempotency
  • Rate limits

Resources

  • Appointments
  • Customers
  • Services
  • Staff
  • Availability
  • Products
  • Payments
  • Webhooks

Recipes

  • Recipe: book from your own website
  • Recipe: sync the diary to a spreadsheet
  • Recipe: a Slack message on every new booking

Reference

  • Changelog
  • OpenAPI 3.1 document

Idempotency

Why creates require an idempotency key, the three outcomes, and how to retry safely.

Last updated 1 August 2026

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

EndpointHeader
POST /api/v1/appointmentsRequired
POST /api/v1/customersRequired
POST /api/v1/webhooksAccepted and ignored
PATCH on any resourceAccepted and ignored
POST /api/v1/appointments/{id}/statusAccepted 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.

1

Same key, same body, first time: the work happens

A normal 201. Nothing distinguishes it from a create without a key.

2

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.

3

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.

On this page

  • Why it is required rather than offered
  • Which endpoints
  • The three outcomes
  • Only a success is stored
  • Picking a key
  • Keys are per business
Stippa

Appointment scheduling, without the hassle.

Try free for 1 month

Product

  • Features
  • Pricing
  • Demo
  • FAQ
  • Contact
  • System status

Features

  • Booking widget
  • Online payments
  • Reminders
  • No-show prevention
  • Online calendar
  • Your own website
  • Client management

For whom

  • For salons
  • Beauty salons
  • Physiotherapists
  • Coaches
  • Personal trainers
  • Barbershops
  • Nail salons
  • Pedicurists
  • Massage salons

Legal

  • Privacy policy
  • Terms of service
  • Data processing agreement

© 2026 Stippa. All rights reserved.

De Rechter Software · Molenwater 20, 4511 BN Breskens · KvK 98466402· VAT NL005332100B80 · +31 6 15745447

······
Try free for 1 month

1 month free. No card needed.

Cookies

We use cookies to measure how visitors use this website. With your consent we also record your visit, so we can see where something goes wrong.

You can change your choice at any time via 'Cookie preferences' at the bottom of every page.

· Learn more