Idempotency
Idempotency
When a POS terminal sends a fiscal command and the network drops mid-request, the safe thing to do is retry. Without protection, that retry prints a second receipt. The Idempotency-Key header guarantees retries land at most once on the device, and that every repeat attempt sees the same response as the first.
The first request with a given key runs and the response is stored. Any later request with the same key from the same organization, within 24 hours, gets the original response back — the command does not run again.
Send the header
Add Idempotency-Key to any POST that supports it:
POST /api/v1/commands HTTP/1.1
Host: api.e-bon.ro
Authorization: Bearer ebon_live_<orgId>_<32-hex>
Content-Type: application/json
Idempotency-Key: order-12345-attempt-1
{ "deviceId": "dev_abc123", "type": "print_receipt", "payload": { "...": "..." } }
The header value must match these rules:
| Property | Value |
|---|---|
| Length | 1 to 128 characters |
| Character set | ^[a-zA-Z0-9_-]+$ — letters, digits, underscores and dashes only |
| Header name | Idempotency-Key (case-insensitive, per HTTP) |
| Optional | Yes. Omit the header to disable caching for that request. |
If the header is present but malformed, the API responds with 400 VALIDATION_ERROR and the request never runs. See VALIDATION_ERROR for the response shape.
Supported endpoints
| Method | Path | Description |
|---|---|---|
POST | /api/v1/commands | Queue a fiscal command against an AMEF. See Commands. |
POST | /api/v1/receipts | Store a printed receipt. See Receipts. |
Sending the header on other endpoints is harmless — the API ignores it.
How replay works
| Scenario | Behavior |
|---|---|
| Header absent or empty | The request runs normally and the response is not cached. |
| Header present, no cached response | The request runs and its response is cached for 24 hours. |
| Header present, cached response within 24 hours | The cached status and body are returned immediately. The command does not run again. |
| Header present, cached response older than 24 hours | The expired entry is discarded and the request runs again, refreshing the cache. |
After 24 hours the same key becomes available again. That's useful if you want to reuse a deterministic key across days, but remember: after the window closes, the API no longer remembers whether the operation already happened.
Things to watch out for
Idempotency-Key alone. If you send the same key with a different payload, you get the original response back and the new payload is silently discarded.Whenever the business payload changes, generate a new key. Rule of thumb: one key per logical attempt at one logical operation. If the items, device, price or customer change, that's a different operation and needs a different key.400 VALIDATION_ERROR, every retry within the next 24 hours gets that same 400 — even if you fix the payload. To recover from a cached error, send a new key.Generate good keys
Pick whichever pattern fits your retry strategy:
- One UUIDv4 per attempt. Generate a fresh UUID right before sending and reuse it only for transparent retries of that same attempt (timeouts,
ECONNRESET, 5xx). When the user clicks Print again — a new logical attempt — generate a new UUID. - Deterministic per business event. Build the key from your own identifiers, e.g.
order_<orderId>_attempt_<n>orshift_<shiftId>_close. This shape is easy to grep in your own logs.
A common combination is order_<id>_attempt_<n>: bump <n> every time the user explicitly retries (so each explicit retry re-prints), while transparent network retries reuse the same <n> (so they replay the cached response).
What not to do:
- Don't reuse a key across distinct operations.
order_12345reused for both aprint_receiptand a follow-upcancel_receiptwill make the second one return the first one's response. - Don't use a wall-clock timestamp alone. Two requests in the same millisecond will collide and it's useless for cross-process deduplication.
- Don't rely on idempotency to dedupe across organizations. Keys are scoped per organization; the same key under two different orgs gives two unrelated cache entries.
Integration recipes
curl: first call and a retry
KEY="order_12345_attempt_1"
# First call — the command runs, response is cached for 24h.
curl -X POST https://api.e-bon.ro/api/v1/commands \
-H "Authorization: Bearer ebon_live_<orgId>_<32-hex>" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d '{
"deviceId": "dev_abc123",
"type": "print_receipt",
"payload": { "items": [ { "name": "Espresso", "unitPrice": 8.5, "quantity": 1, "vatRate": 9 } ] }
}'
# → 202 Accepted, { "command": { "id": "cmd_001", "status": "pending", ... } }
# Network blip → safe retry with the SAME key.
# The command does NOT run again; the cached body is returned verbatim.
curl -X POST https://api.e-bon.ro/api/v1/commands \
-H "Authorization: Bearer ebon_live_<orgId>_<32-hex>" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d '{ "deviceId": "dev_abc123", "type": "print_receipt", "payload": { "...": "..." } }'
# → 202 Accepted, { "command": { "id": "cmd_001", "status": "pending", ... } } — same body as above
Node: retry helper with fetch
import { randomUUID } from 'node:crypto';
const API = 'https://api.e-bon.ro';
const TOKEN = process.env.EBON_API_KEY!; // ebon_live_<orgId>_<32-hex>
async function sendCommandWithRetry(body: unknown, maxAttempts = 3): Promise<unknown> {
const key = `cmd_${randomUUID()}`; // one key per logical attempt
let lastErr: unknown;
for (let i = 0; i < maxAttempts; i++) {
try {
const res = await fetch(`${API}/api/v1/commands`, {
method: 'POST',
headers: {
Authorization: `Bearer ${TOKEN}`,
'Content-Type': 'application/json',
'Idempotency-Key': key, // SAME key on every transparent retry
},
body: JSON.stringify(body),
});
if (res.ok) return await res.json();
// Cached errors will replay for 24h — only retry on transient transport failures.
if (res.status >= 500) throw new Error(`transient ${res.status}`);
return await res.json(); // 4xx — surface to caller, do not retry with same key
} catch (err) {
lastErr = err;
await new Promise((r) => setTimeout(r, 250 * 2 ** i));
}
}
throw lastErr;
}
When the user explicitly retries (clicks Print again after seeing a failure), call sendCommandWithRetry() again — randomUUID() produces a fresh key for the new logical attempt, so the device is asked to print again instead of replaying the previous cached response.
If you prefer deterministic keys, swap randomUUID() for something like `order_${orderId}_attempt_${attemptNumber}` and bump attemptNumber only on user-driven retries.
See also
- Commands — the
POST /api/v1/commandsendpoint that honorsIdempotency-Key. - Receipts — the
POST /api/v1/receiptsendpoint that honorsIdempotency-Key. VALIDATION_ERROR— the response when the header is malformed.- API overview — base URL, error envelope, rate limits and idempotency at a glance.
Health, identity & meta endpoints
Reference for the public health probes, the authenticated identity introspection endpoint, the robots exclusion file and the OpenAPI surface (Swagger UI + raw spec) exposed by the e-bon API.
Events WebSocket
Wire-protocol reference for the e-bon real-time events WebSocket — connect URL, JWT and API-key auth, the eight event types, scope-based filtering, frame shape, heartbeat, rate limit, close codes and reconnect guidance.