Idempotency

A request that timed out may still have succeeded. If your POS simply sends it again, the customer could get their stamps twice. An Idempotency-Key makes the retry safe: ServeU remembers the first answer and gives it back instead of doing the work again.

Sending a key

  • Send Idempotency-Key on every write: 1–100 visible ASCII characters (no spaces). Use something that is unique per action, such as your receipt number (receipt-10422) or a UUID.
  • It is required on earn, redeem, void and order actions: without it they answer 400 idempotency_key_required. Joining a customer and menu writes take one optionally.
  • Keys are per API key: two systems can't collide.

What happens on a retry

  • Same key and same request within 24 hours: you get the stored answer, with the header Idempotent-Replayed: true. Error answers are stored and replayed too.
  • So a stored error is the answer for that key for 24 hours. When you fix its cause (the order is now paid, you send fewer stamps, the owner set up a loyalty program), send the request with a new key: retrying with the old one answers the same payment_required, order_not_actionable, card_cap_reached or no_program again. A key that includes the state you acted on (an order's updated_at, the stamps you send) changes by itself.
  • "Same request" means the same method, path and body, byte for byte: when you retry, send the same JSON text, not a rebuilt one with fields in another order.
  • Same key with a different request: 409 idempotency_conflict.
  • The first request is still running: 409 idempotency_in_progress with Retry-After: 1. Wait a second and retry with the same key.
  • Not stored, so retrying with the same key is safe: 5xx answers, 409 conflict, 409 idempotency_in_progress and 429 rate_limited. Refusals about the key or the business aren't stored either, even when they happen mid-request (401 unauthorized, 403 insufficient_scope, api_disabled, business_suspended), nor is a body that fails validation before the request runs (400 invalid_request with field details): once the owner has fixed the key or you've fixed the body, the same key runs the request.
  • A replayed error keeps the original request_id in its body; the X-Request-Id header is the retry's own.
  • A request runs for at most 30 seconds, so set your client timeout above that, or retry with the same key and handle idempotency_in_progress.

A retry loop

import json, time, requests

BASE = "https://serveu-psi.vercel.app/api/v1"
key = f"receipt-{receipt_no}"
# Build the body once, so every retry sends exactly the same bytes.
body = json.dumps({"stamps": 1, "reference": str(receipt_no)})
headers = {
    "Authorization": f"Bearer {SERVEU_API_KEY}",
    "Content-Type": "application/json",
    "Idempotency-Key": key,
}

for attempt in range(5):
    try:
        r = requests.post(f"{BASE}/cards/{card_id}/earn", headers=headers, data=body, timeout=35)
    except requests.RequestException:
        time.sleep(2 ** attempt)
        continue
    if r.status_code >= 500 or r.status_code == 429:
        time.sleep(int(r.headers.get("Retry-After", 2 ** attempt)))
        continue
    if r.status_code == 409 and r.json()["error"]["code"] in ("idempotency_in_progress", "conflict"):
        time.sleep(1)
        continue
    break  # done: a success, or an answer to show (e.g. card_cap_reached)
else:
    raise RuntimeError(f"ServeU API: no answer for {key} after 5 attempts")
Earns and redeems are also protected inside the ledger: even after the 24 hours, the same API key and Idempotency-Key can never earn twice. Voids are protected by the event itself: retrying a void that already happened answers 409 already_voided, which means it is done.

Next: Webhooks →