Skip to content
Core Concepts

Idempotency

Network failures, container restarts, client-side timeouts — anything that leaves a POST in an ambiguous “did it land?” state is fixed with the Idempotency-Key header.

Use an idempotency key on every POST that has side effects you can’t afford to double:

  • POST /v1/messages — duplicate sends spam your customer.
  • POST /v1/webhook_subscriptions/{id}/secrets — duplicate rotations invalidate the wrong secrets.
  • POST /v1/contacts/bulk_label — duplicate attaches are no-ops but duplicate detaches followed by an attach create a confusing audit trail.

GET requests are naturally idempotent — no key needed.

Pass a client-generated key in the header:

POST /v1/messages HTTP/1.1
Authorization: Bearer kdv_live_…
Idempotency-Key: 8e1a2c30-f0a4-4c70-9c2d-7b5e3aef9201
Content-Type: application/json
{ "to": "+628…", "type": "text", "text": { "body": "Halo" } }

The key is a free-form string. Recommended format: UUIDv4. Maximum length 255 characters. Keep it cryptographically random to avoid collisions across consumers within the same organisation.

The key is scoped to your organisation. Kirim hashes the request body (sha256(method + path + canonical_json(body))) and stores the pair in Redis for 24 hours:

ScenarioBehaviour
First callProcess normally, cache the response. Add Idempotent-Replayed: false to the response headers.
Replay with same key + same bodyReturn the cached response verbatim, including status code and body. Add Idempotent-Replayed: true.
Replay with same key + different bodyReturn 422 idempotency_key_reuse. Don’t lie about the side effect outcome.
In-flight (lock held by an earlier call)Wait up to 30 s for the result. On timeout, return 409 idempotency_in_progress. Retry later with the same key.
import { randomUUID } from 'node:crypto'
async function sendOnce(payload: SendMessageBody): Promise<MessageResource> {
// Generate ONE idempotency key per logical "send attempt".
// Reuse it across network-level retries; generate fresh for a new send.
const key = randomUUID()
for (let attempt = 0; attempt < 3; attempt++) {
const res = await fetch('https://api-kckit.kirim.chat/v1/messages', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.KIRIM_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': key, // SAME key across retries
},
body: JSON.stringify(payload),
})
if (res.ok) return (await res.json()).data
if (res.status === 409) { // still processing — back off
await sleep(1000 * (attempt + 1))
continue
}
if (res.status === 422) { // key reuse with different body
throw new Error('Idempotency key collision — caller bug')
}
if (res.status === 429) { // rate-limited
const delay = Number(res.headers.get('retry-after') ?? 1)
await sleep(delay * 1000)
continue
}
// 4xx other than 409/422/429 — don't retry, surface to caller.
throw await res.json()
}
throw new Error('Idempotency retry budget exhausted')
}

Cached responses expire 24 hours after the first call. After that, the same key + same body executes a fresh side effect (a new message gets sent). Pick keys that won’t be replayed beyond a day.

For longer dedup windows, persist your own “have I sent message X yet?” record (keyed by your own request id) and consult it before calling Kirim.

  • Don’t reuse a key across logically different sends. A single key represents a single intent. If you’re sending two distinct messages, generate two keys.
  • Don’t generate a fresh key on every retry. The whole point of the key is to dedupe — a new key per attempt sends a new message per attempt.
  • Don’t use predictable keys (sequential integers, user id + timestamp). Even within your own org, predictable keys risk collisions across services.
  • Don’t truncate or hash the key client-side. Pass the full string you generated.

Idempotency-Key is honoured on every POST in the v1 surface. It is ignored on GET, PATCH, and DELETE — those are either naturally idempotent or operate by primary key, so re-applying them produces the same final state without the cache layer.