Idempotency
Network retries are a fact of life. Without protection, a retry after a timeout can create a duplicate punch, duplicate webhook endpoint, or duplicate time-off request. The Buddy Punch Public API supports idempotency keys so you can retry any write safely.
What Is an Idempotency Key?
You generate a unique key per logical operation (a UUID v4 works well) and send it in the Idempotency-Key request header. The API:
- First request with that key: processes normally, caches the response body + status code.
- Any later request with the same key (same API key): returns the cached response instead of re-executing the operation. The replay carries the header
Idempotent-Replayed: trueso you can tell it apart.
This means a retry after a timeout returns the result of the original request — no duplicates.
Which Endpoints Support It
Every mutating endpoint accepts Idempotency-Key:
POST— resource creationPUT— full-resource updatePATCH— partial updateDELETE— removal
GET requests ignore the header (reads are already idempotent by design).
The header is optional. If you don't send it, the request proceeds normally with no replay protection.
Using the Header
curl -X POST https://api2.buddypunch.com/punch \
-H "Authorization: Bearer bp_live_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 8b3c29e1-4a7d-4c6a-9c2f-1a3e9f5d7b4c" \
-d '{
"employeeId": 4521,
"timeCardId": 5678,
"punchInDatetime": "2026-03-17T08:00:00"
}'
On the first call, the response does not include an Idempotent-Replayed header at all — treat the absence of the header as "this was a fresh execution."
On replay, the response headers include:
Idempotent-Replayed: true
Both responses have identical status code and body — the replay returns exactly what the first call returned, byte-for-byte.
Key Format
- Any string up to 255 characters, but UUID v4 is strongly recommended — it's random enough to avoid collisions and easy to generate in every language.
- Treat the key as opaque. The API doesn't inspect or validate its structure.
- Generate a new key for every logically distinct operation. Two unrelated
POST /punchcalls must use different keys, or the second call will return the first call's response.
Example key: 8b3c29e1-4a7d-4c6a-9c2f-1a3e9f5d7b4c
Scope
- Keys are cached by (API key, idempotency key) — nothing else. Two different API keys can send the same idempotency-key string at the same time with no conflict.
- Method and path are NOT part of the cache key. If you send
POST /punchwith keyKand later sendPUT /punch/1/inwith the same keyKunder the same API key, the later request will replay the earlier cached response (wrong status code, wrong body) instead of executing. This is almost always a bug. - Do not reuse an idempotency key across endpoints, HTTP methods, or logically distinct operations. Always generate a fresh key per operation — the
randomUUID()/uuid4()/Guid.NewGuid()call belongs in the code that starts each operation, not somewhere shared.
TTL
Records are retained for 24 hours after creation. After that window the key is free to reuse (the server has no memory of it).
Plan on this window being enough for:
- Normal retry cascades (seconds to minutes).
- Overnight batch jobs.
- Client crashes followed by same-day resumption.
If your workflow spans more than 24 hours between attempts, treat each attempt as a new operation and use a new key.
Conflict Semantics
There are two different situations that are sometimes conflated:
Replay (happy path)
Same API key + same Idempotency-Key + within 24 h = you get back the cached response (same status, same body, Idempotent-Replayed: true). No conflict error is raised. The request body and method don't need to match exactly — the cached result wins.
True 409 conflict
An endpoint returns 409 Conflict with error.code = "idempotency_conflict" when the business operation itself fails a uniqueness constraint — for example, creating a resource that would duplicate an existing one. This is independent of the idempotency-key system. Resolve by sending a corrected request; a new key won't help until the underlying conflict is gone.
Concurrent identical requests
Idempotency protects sequential retries — the retry happens after the first request has returned a response, so the replay finds the cached entry and short-circuits.
It does not protect against parallel requests that reach the server before the first one finishes. Both requests will see no cached record on arrival, both will execute the underlying handler, and both will commit their side effects (create a punch, send a payroll update, etc.) before either idempotency record is stored. The duplicate-insert race on the idempotency table itself is handled silently, but that does not unwind the double-execution of the business logic.
Do not fire two requests in parallel with the same key and expect deduplication. Send one, wait for it to return (or time out), then retry with the same key if you need to.
Best Practices
- Use a UUID v4. Most stdlib / framework utilities generate one:
crypto.randomUUID()in Node.js,uuid.uuid4()in Python,Guid.NewGuid()in C#. - Generate the key before the first attempt, not before each retry. All retries of the same logical operation must share the same key.
- Persist the key with the outgoing request. If your process restarts mid-retry, reload the pending operation's key from storage rather than generating a new one.
- Never reuse a key across different operations. It doesn't matter that the API scopes per (API key, key) pair — keep them unique per operation for your own sanity.
- Use it for every write, not just the "risky" ones. The overhead is negligible and it removes a whole class of data-corruption bugs.
Example: A Safe Retry Loop
import { randomUUID } from 'crypto';
async function createPunchWithRetry(body) {
const idempotencyKey = randomUUID();
let attempt = 0;
const maxAttempts = 3;
while (attempt < maxAttempts) {
try {
const res = await fetch('https://api2.buddypunch.com/punch', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.BP_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(body),
});
if (res.ok || (res.status >= 400 && res.status < 500 && res.status !== 429)) {
// Success or non-retryable client error — done.
return res;
}
// 429 or 5xx — retry with backoff (same idempotency key!)
const wait = res.status === 429
? Number(res.headers.get('retry-after') || 1) * 1000
: Math.pow(2, attempt) * 1000;
await new Promise(r => setTimeout(r, wait));
attempt++;
} catch (networkErr) {
// Network/timeout — retry with the same key
await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000));
attempt++;
}
}
throw new Error(`Failed after ${maxAttempts} attempts`);
}
The critical line is const idempotencyKey = randomUUID(); outside the retry loop — every sequential retry shares the same key, so reusing the same operation after a timeout or transient failure returns the original result instead of creating a duplicate. This protects against duplicate side effects from sequential retries, but does not change the earlier caveat that parallel requests sent at the same time with the same key can still race before the idempotency record is stored.
Related Guides
- Getting Started — auth, envelope, first request
- Errors — error catalog and retry recommendations
- Code Examples — working retry loops in curl / Node.js / Python / C#
- Webhooks — webhook delivery retries use the same idempotency philosophy