Skip to content

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”.

Terminal window
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 header name is not the interesting part; the replay behaviour is.

  1. First request with a key — processed normally. The response, including its status code and body, is recorded against the key.
  2. Repeat with the same key and the same body — the recorded response is replayed. No second resource is created. You get the original 201 and the original chl_… id, not a new one.
  3. Repeat with the same key and a different body409 with idempotency_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.
  4. Repeat while the first request is still in flight409 with idempotency_key_in_progress. Wait a second or two and retry with the same key.
  5. Errors are replayed too. If the first request failed validation with a 400, replaying the key returns that 400. 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.

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.

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.
  • GET requests 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.