GuidesBest Practices

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:

VerticalCreate endpointFieldList lookup
VouchersPOST /api/v1/ordersclient_referenceGET /api/v1/orders?client_reference=…
TopupsPOST /api/v1/topups/ordersclient_referenceGET /api/v1/topups/orders?client_reference=…
eSIMPOST /api/v1/esim/ordersclient_referenceGET /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) returns 400 Bad Request with 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_reference before 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?

EndpointIdempotent?How
POST /api/v1/orders✅ via client_referenceUnique (client_id, client_reference) index
POST /api/v1/topups/orders✅ via client_referenceUnique (client_id, client_reference) index
POST /api/v1/esim/orders✅ via client_referenceUnique (client_id, client_reference) index
POST /api/v1/products/:id/charges✅ pure read-shaped POSTNo side effects
POST /api/v1/topups/charges✅ pure read-shaped POSTNo side effects
POST /api/v1/esim/charges✅ pure read-shaped POSTNo side effects
POST /auth/login⚠️ not per-requestEach call mints a new token pair
POST /auth/refresh❌ noConsumes the refresh token
All GET endpointsSafe 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

  1. Generating client_reference inside 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.
  2. Using a timestamp as client_reference. Two retries a millisecond apart can collide, and clock skew makes them look identical across different orders.
  3. Treating "Duplicate client_reference" as a failure. It's success with extra confirmation — the first attempt landed. Look up the order and continue.
  4. Omitting client_reference to "let the server handle it". Works fine until you need to retry — then you have two orders.
  5. Looking up by numeric id after a duplicate. You may not have the id if your first attempt's response was lost. The whole point of client_reference is 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 by client_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.

On this page