Idempotency
Every POST on this API accepts an Idempotency-Key header. Send one on every
POST, every time. It is the difference between “the network hiccuped” and “the
donor got two checkout links”.
curl https://api.vaki.cohttps://public-api-staging.vaki.co/v1/checkout_links \ -H "Authorization: Bearer $VAKI_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: donation-DON_59LPC74YV" \ -d '{ "vaki": "clinicalaliga", "amount": 50000, "currency": "COP" }'The semantics, precisely
Section titled “The semantics, precisely”The header name is not the interesting part; the replay behaviour is.
- First request with a key — processed normally. The response, including its status code and body, is recorded against the key.
- Repeat with the same key and the same body — the recorded response is
replayed. No second resource is created. You get the original
201and the originalchl_…id, not a new one. - Repeat with the same key and a different body —
409withidempotency_key_reused. Nothing is created. This is a bug in your code, and the API refuses to guess which of the two bodies you meant. - Repeat while the first request is still in flight —
409withidempotency_key_in_progress. Wait a second or two and retry with the same key. - Errors are replayed too. If the first request failed validation with a
400, replaying the key returns that400. To retry with a corrected body, use a new key — the old one is bound to the old answer.
Keys are retained for 24 hours. After that a key is forgotten and reusing it starts a fresh operation. Since you should never be retrying a day-old request, this only matters if you derive keys from something long-lived and replay it much later.
Choosing a key
Section titled “Choosing a key”The rule: one key per logical operation, derived from your own data. Not random, not per HTTP attempt.
// Good — derived from your record. Every retry of this operation reuses it.'Idempotency-Key': `donation-${donation.id}`'Idempotency-Key': `vaki-${need.id}`
// Bad — a new key per attempt defeats the entire mechanism.'Idempotency-Key': crypto.randomUUID()'Idempotency-Key': `donation-${Date.now()}`If you generate the key inside the retry loop, every retry is a new operation and you have built exactly the double-creation bug the header prevents.
Keys are scoped to your client, so they only have to be unique to you. Up to 255 characters; a readable prefix plus your own id is the shape that pays off when you are reading logs at 2am.
Why this matters more than it looks
Section titled “Why this matters more than it looks”The case idempotency exists for is not the retry you wrote on purpose. It is:
- A timeout. Your HTTP client gave up at 10 seconds; the request completed at 11. You do not know whether it succeeded. Retrying with the same key is the only safe move — you either get the original result or the operation runs once.
- A double-submitted form. The donor clicked “Donar” twice. If your route derives the key from your donation id, the second click returns the first link instead of minting a second one.
- A framework-level retry. Serverless platforms and queue workers retry on their own schedule, sometimes without telling you. A derived key makes that safe by construction rather than by hoping.
Where it does not apply
Section titled “Where it does not apply”GETrequests are already idempotent. The header is ignored.- Read-only retries need no key. Poll
GET /v1/checkout_links/{id}as often as your rate limit allows.
When withdrawals ship, idempotency stops being an optimisation and becomes a hard
requirement: a retried payout request must never produce a second transfer. That
endpoint will reject a POST without an Idempotency-Key outright. Building the
habit now on checkout links costs you nothing and means that change is a no-op for
you.