Buddy Punch

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:

  1. First request with that key: processes normally, caches the response body + status code.
  2. 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: true so 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:

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

Example key: 8b3c29e1-4a7d-4c6a-9c2f-1a3e9f5d7b4c


Scope


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:

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


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.