Idempotency
Safe retrying without duplicating orders — the client_reference field and how to use it across vouchers, topups, and eSIM
Order creation is the one operation where a retry gone wrong costs money. Every create endpoint protects you with a uniqueness constraint on the client_reference field: send the same client_reference twice and the second call is rejected with a clean 400. Build your retry logic around that guarantee.
The same model applies to all three verticals:
| Vertical | Create endpoint | Field | List lookup |
|---|---|---|---|
| Vouchers | POST /api/v1/orders | client_reference | GET /api/v1/orders?client_reference=… |
| Topups | POST /api/v1/topups/orders | client_reference | GET /api/v1/topups/orders?client_reference=… |
| eSIM | POST /api/v1/esim/orders | client_reference | GET /api/v1/esim/orders?client_reference=… |
If you omit client_reference, the order is still created — but with no client-side dedup key it cannot survive a retry. For any retry-capable code path, always supply your own. Server-generated identifiers exist (numeric id in the response, plus internal references) but they are returned in the response, not pre-computable — useless if your first attempt's response was lost.
The client_reference field
Send client_reference in the create request body:
{
"product_id": 123,
...
"client_reference": "order-a8f2c1d9"
}Rules (same across all three verticals):
- Optional, but required for safe retries.
- Up to 255 characters, printable ASCII only (no control characters).
- Unique per client — duplicate
(client_id, client_reference)returns400 Bad Requestwith the message"Duplicate client_reference". No second order is created. - Echoed back in the create response and on every subsequent fetch under the same field name.
The dedupe check is a hard 400 — there is no idempotent silent-replay. That is intentional: a duplicate is a signal that your retry already landed, not a request to repeat work.
Constructing a good client_reference
✓ UUID v4: "f47ac10b-58cc-4372-a567-0e02b2c3d479"
✓ KSUID: "2F7vTVG3Y8aZrqP1wJhdEkM9oYB"
✓ Your IDs: "client-42:campaign-q1:voucher-00173"
✗ Timestamps: "2026-04-22T17:00:00Z" (collision risk on retries)
✗ Sequential: "1", "2", "3" (brittle, collides across deploys)A good client_reference:
- Is globally unique in your system.
- Is determined before the first request attempt, not per-attempt.
- Is short enough to fit your database indexes and log statements comfortably.
- Encodes enough context that you can find the originating operation from the value alone — useful in support conversations.
The retry pattern
1. Generate client_reference (UUID or KSUID) client-side
2. Persist {client_reference, request_body} → your database
3. Attempt POST /<vertical>/orders with that client_reference
4. On 2xx → store the returned id alongside your client_reference, mark done
5. On transient error (5xx, timeout, network) → retry with the SAME client_reference (back to 3)
6. On 400 "Duplicate client_reference" → the order already exists. Look it up by client_reference.The key moves:
- Persist
client_referencebefore the first request. If your process crashes mid-request, restart with the same value. - Only retry on transient errors. 5xx, connection error, timeout. Any other 4xx should surface to the caller.
- Treat a duplicate as success-plus-confirmation, not failure. Fetch the existing order and continue.
Looking up after a duplicate
When you get 400 "Duplicate client_reference", the order does exist. Fetch it by passing client_reference as a query parameter to the list endpoint:
# Vouchers
curl "$HOST/api/v1/orders?client_reference=order-a8f2c1d9" \
-H "Authorization: Bearer $TOKEN"
# Topups
curl "$HOST/api/v1/topups/orders?client_reference=order-a8f2c1d9" \
-H "Authorization: Bearer $TOKEN"
# eSIM
curl "$HOST/api/v1/esim/orders?client_reference=order-a8f2c1d9" \
-H "Authorization: Bearer $TOKEN"The match is exact (not prefix or fuzzy) and the uniqueness constraint guarantees at most one result. An empty result is anomalous — fall back to retrying the original POST.
A retry-safe create wrapper
The same loop works for all three verticals — only the path changes.
package orders
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"time"
"github.com/segmentio/ksuid"
)
type CreateRequest struct {
// Whatever fields the vertical's create endpoint expects, plus:
ClientReference string `json:"client_reference"`
}
func Place(client *http.Client, host, token, pathBase string, req CreateRequest) (map[string]any, error) {
if req.ClientReference == "" {
req.ClientReference = ksuid.New().String()
}
// Persist (client_reference, request_body) to your own DB here, keyed by
// client_reference. On retry, load the existing client_reference instead
// of minting a new one.
body, _ := json.Marshal(req)
for attempt := 1; attempt <= 5; attempt++ {
httpReq, _ := http.NewRequest("POST", host+pathBase, bytes.NewReader(body))
httpReq.Header.Set("Authorization", "Bearer "+token)
httpReq.Header.Set("Content-Type", "application/json")
resp, err := client.Do(httpReq)
if err != nil {
time.Sleep(backoff(attempt))
continue
}
switch {
case resp.StatusCode >= 200 && resp.StatusCode < 300:
var out map[string]any
_ = json.NewDecoder(resp.Body).Decode(&out)
_ = resp.Body.Close()
// Persist out["id"] alongside your client_reference here.
return out, nil
case resp.StatusCode == 400 && isDuplicateClientRef(resp):
_ = resp.Body.Close()
return lookupByClientRef(host, token, pathBase, req.ClientReference)
case resp.StatusCode >= 500:
_ = resp.Body.Close()
time.Sleep(backoff(attempt))
continue
default:
// Non-retriable 4xx — surface to caller.
return nil, parseError(resp)
}
}
return nil, errors.New("exhausted retries")
}Which endpoints are idempotent?
| Endpoint | Idempotent? | How |
|---|---|---|
POST /api/v1/orders | ✅ via client_reference | Unique (client_id, client_reference) index |
POST /api/v1/topups/orders | ✅ via client_reference | Unique (client_id, client_reference) index |
POST /api/v1/esim/orders | ✅ via client_reference | Unique (client_id, client_reference) index |
POST /api/v1/products/:id/charges | ✅ pure read-shaped POST | No side effects |
POST /api/v1/topups/charges | ✅ pure read-shaped POST | No side effects |
POST /api/v1/esim/charges | ✅ pure read-shaped POST | No side effects |
POST /auth/login | ⚠️ not per-request | Each call mints a new token pair |
POST /auth/refresh | ❌ no | Consumes the refresh token |
All GET endpoints | ✅ | Safe by definition |
For non-idempotent auth endpoints, rely on the short-lived nature of the consumed resource: refresh token expiry is 7 days, so a retry window of seconds is acceptable.
Common mistakes
- Generating
client_referenceinside the retry loop. The first attempt and the retry end up with different values — duplicate protection doesn't kick in, and you create two orders. - Using a timestamp as
client_reference. Two retries a millisecond apart can collide, and clock skew makes them look identical across different orders. - Treating "Duplicate client_reference" as a failure. It's success with extra confirmation — the first attempt landed. Look up the order and continue.
- Omitting
client_referenceto "let the server handle it". Works fine until you need to retry — then you have two orders. - Looking up by numeric
idafter a duplicate. You may not have theidif your first attempt's response was lost. The whole point ofclient_referenceis that it's known to both sides ahead of time.
Summary
- Always send your own
client_reference— UUID / KSUID / namespaced string, ≤ 255 ASCII chars. - Generate it once, persist it, reuse it on retries.
- Treat
400 "Duplicate client_reference"as "already done — go fetch byclient_reference". - The same model — same field, same error, same lookup — works on all three verticals.
- Retry only on 5xx, timeouts, and network errors. Other 4xx surface to the caller.