# Idempotency and retries

> Retry any order request safely, and never send or charge a gift twice.


Networks fail. A request can time out after we've created the order, and a retry without protection would send and charge every gift a second time. The `Idempotency-Key` header prevents that: send the same key again and you get the order it already created, not a new one.

## Why it's required

`POST /v1/orders` moves money, so we require an `Idempotency-Key` header on every call. A request without one is rejected with [`parameter_invalid`](https://business.onme.com/docs/reference#errors/parameter_invalid) and `param: "Idempotency-Key"`, before anything else happens.

With a key, the rules are simple:

- The first request with a key creates the order.
- Every later request with that key and the same body returns that same order.
- There's no expiry window: a key that created an order returns that order whenever you send it.

Keys are scoped to your organization, so they never collide with another organization's keys. No other endpoint takes a key. The `GET` endpoints only read, and `PUT /v1/gifts/{gift_id}/delivery` can't send a gift twice, because it only works while the gift's delivery has failed.

## Choosing a key

A key is 1 to 255 printable ASCII characters. Use one of these:

- **A random UUID v4**, generated once per order: `crypto.randomUUID()` in Node, `uuid.uuid4()` in Python, `uuidgen` in a shell. Save it with your record before you send the request, so a retry after a crash can reuse it.
- **A value derived from your own order ID**, such as `order-${yourOrderId}`. A retry then produces the same key without storing anything extra. See [the recipe](https://business.onme.com/docs/creating-orders#idempotency-keys-from-your-own-order-ids).

Whichever you choose:

- **Use one key per distinct order.** If one of your records can lead to more than one On Me order, include what makes each one different, such as a date or attempt number.
- **Keep personal data out.** Don't put emails, phone numbers or names in a key.
- **Stay within printable ASCII.** Characters outside space (`0x20`) to tilde (`0x7E`), or a key over 255 characters, fail with `parameter_invalid`.

## What happens when you repeat a request

| You send | You get |
| --- | --- |
| The same key and the same body as an order that was created | `200 OK` with that order, plus an `Idempotent-Replayed: true` header. Nothing new is created or charged. |
| The same key with a different body | `409` [`idempotency_key_reused`](https://business.onme.com/docs/reference#errors/idempotency_key_reused). The original order is unchanged. |
| The same key while the first request is still running | We wait up to about five seconds for it to finish and return its result. If it's still running, you get `409` [`idempotency_in_progress`](https://business.onme.com/docs/reference#errors/idempotency_in_progress); retry shortly. |
| A key whose earlier requests all got error responses | The request is processed as new. Error responses never use up a key, because a key binds to an order only when we debit your balance. |

"The same body" compares the request after validation, so the order of keys, the case of an email address and the formatting of a phone number (`+1 (415) 555-1234` versus `+14155551234`) don't matter. Any change to an amount, category, recipient or name does.

Because errors never use up a key, you can fix a request that failed with `parameter_invalid` or `insufficient_balance` and resend it with the same key, even though the body changed.

## When to retry

Every outcome falls into one of three cases:

| What you got | What it means | What to do |
| --- | --- | --- |
| A `2xx` response | The order exists. `201` means this request created it; `200` means it's a replay. | Done. If `status` is `processing`, poll the order until it's `processed`; see [When the status is processing](https://business.onme.com/docs/creating-orders#when-the-status-is-processing). |
| A response with an `error` object | Nothing happened: no order, no charge, no gifts. | Follow the code's retry rule in the [error reference](https://business.onme.com/docs/reference#errors). Codes marked **Retry: yes** (`idempotency_in_progress`, `resource_locked`, `rate_limit_exceeded` and `internal_error`) can be resent unchanged after `Retry-After`. **Retry: after fix** codes need you to fix the request, key, balance or account first. **Retry: no** codes can't succeed. |
| No response, a timeout, or a response without an `error` object | The outcome is unknown. The order may or may not exist. | Retry with the same key and the same body. You get the order if it exists, or a new one if it doesn't, never two. |

The third case is the one that matters most. A `502` or `504` from a proxy, a dropped connection or a client timeout doesn't tell you whether we created the order. Retrying with a new key in that situation is how duplicate gifts happen.

## A complete retry loop

This function sends an order and retries it safely. It makes up to five attempts with the same key, using exponential backoff with jitter capped at 10 seconds and honouring `Retry-After`. It retries network errors, any response without an `error` object (the outcome is unknown), `409` with `idempotency_in_progress` or `resource_locked`, `429`, and `5xx`. It returns on success or on an error that retrying can't fix.

**Node**

```js
const RETRYABLE_409_CODES = new Set(['idempotency_in_progress', 'resource_locked'])
const MAX_ATTEMPTS = 5

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))

function backoffMs(attempt, retryAfterHeader) {
  const exponential = Math.min(10_000, 500 * 2 ** (attempt - 1))
  const jittered = exponential / 2 + Math.random() * (exponential / 2)
  const retryAfterMs = Number(retryAfterHeader) * 1000 || 0
  return Math.max(jittered, retryAfterMs)
}

function isRetryable(status, code) {
  if (status === 429 || status >= 500) return true
  return status === 409 && RETRYABLE_409_CODES.has(code)
}

// Only an error in our envelope proves nothing happened; anything else is an unknown outcome.
function onmeError(payload) {
  const error = payload?.error
  return typeof error?.type === 'string' && typeof error?.code === 'string' ? error : undefined
}

export async function createOrderWithRetry(body, key) {
  let lastProblem

  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
    let response
    try {
      response = await fetch('https://api.onme.com/v1/orders', {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${process.env.ONME_API_KEY}`,
          'Content-Type': 'application/json',
          'Idempotency-Key': key,
        },
        body: JSON.stringify(body),
        signal: AbortSignal.timeout(30_000),
      })
    } catch (networkError) {
      // No response: the outcome is unknown, so retry with the same key.
      lastProblem = networkError
      if (attempt < MAX_ATTEMPTS) await sleep(backoffMs(attempt))
      continue
    }

    const payload = await response.json().catch(() => null)

    if (response.ok) {
      return { ok: true, status: response.status, order: payload }
    }

    const error = onmeError(payload)
    // A response without our error object (such as a proxy's 403 or 408) is an unknown outcome.
    if (error && !isRetryable(response.status, error.code)) {
      // Nothing happened, and resending the same request can't succeed.
      return { ok: false, status: response.status, error }
    }

    lastProblem = error ?? new Error(`HTTP ${response.status} without an error body`)
    if (attempt < MAX_ATTEMPTS) {
      await sleep(backoffMs(attempt, response.headers.get('Retry-After')))
    }
  }

  // Out of attempts. If the last failure had no error body, the order may exist:
  // retry later with the same key to find out.
  throw new Error(`Order not confirmed after ${MAX_ATTEMPTS} attempts; retry later with key ${key}`, {
    cause: lastProblem,
  })
}
```
**Python**

```python
import os
import random
import time

import requests

RETRYABLE_409_CODES = {"idempotency_in_progress", "resource_locked"}
MAX_ATTEMPTS = 5


def backoff_seconds(attempt, retry_after_header=None):
    exponential = min(10.0, 0.5 * 2 ** (attempt - 1))
    jittered = exponential / 2 + random.random() * (exponential / 2)
    try:
        retry_after = float(retry_after_header) if retry_after_header else 0.0
    except ValueError:
        retry_after = 0.0
    return max(jittered, retry_after)


def is_retryable(status, code):
    if status == 429 or status >= 500:
        return True
    return status == 409 and code in RETRYABLE_409_CODES


# Only an error in our envelope proves nothing happened; anything else is an unknown outcome.
def onme_error(payload):
    error = payload.get("error") if isinstance(payload, dict) else None
    if isinstance(error, dict) and isinstance(error.get("type"), str) and isinstance(error.get("code"), str):
        return error
    return None


def create_order_with_retry(body, key):
    last_problem = None

    for attempt in range(1, MAX_ATTEMPTS + 1):
        try:
            response = requests.post(
                "https://api.onme.com/v1/orders",
                headers={
                    "Authorization": f"Bearer {os.environ['ONME_API_KEY']}",
                    "Idempotency-Key": key,
                },
                json=body,
                timeout=30,
            )
        except requests.RequestException as network_error:
            # No response: the outcome is unknown, so retry with the same key.
            last_problem = network_error
            if attempt < MAX_ATTEMPTS:
                time.sleep(backoff_seconds(attempt))
            continue

        try:
            payload = response.json()
        except ValueError:
            payload = None

        if response.ok:
            return {"ok": True, "status": response.status_code, "order": payload}

        error = onme_error(payload)
        # A response without our error object (such as a proxy's 403 or 408) is an unknown outcome.
        if error and not is_retryable(response.status_code, error.get("code")):
            # Nothing happened, and resending the same request can't succeed.
            return {"ok": False, "status": response.status_code, "error": error}

        last_problem = error or f"HTTP {response.status_code} without an error body"
        if attempt < MAX_ATTEMPTS:
            time.sleep(backoff_seconds(attempt, response.headers.get("Retry-After")))

    # Out of attempts. If the last failure had no error body, the order may exist:
    # retry later with the same key to find out.
    raise RuntimeError(
        f"Order not confirmed after {MAX_ATTEMPTS} attempts; retry later with key {key}: {last_problem}"
    )
```

Call it with a key you've already saved, so a crash in your own process can resume with the same key:

**Node**

```js
import { randomUUID } from 'node:crypto'

const key = reward.idempotencyKey ?? (await saveIdempotencyKey(reward, randomUUID()))
const result = await createOrderWithRetry(orderBody, key)

if (result.ok) {
  await markSent(reward, result.order)
} else {
  const { code, message, request_id } = result.error ?? {}
  console.error(`Order refused (HTTP ${result.status}): ${code} ${message} ${request_id}`)
}
```
**Python**

```python
import uuid

key = reward.idempotency_key or save_idempotency_key(reward, str(uuid.uuid4()))
result = create_order_with_retry(order_body, key)

if result["ok"]:
    mark_sent(reward, result["order"])
else:
    error = result.get("error") or {}
    print(f"Order refused (HTTP {result['status']}): {error.get('code')} {error.get('message')} {error.get('request_id')}")
```

> **Tip: Out of attempts is not the same as failed** When the loop gives up after network errors or bodiless `5xx` responses, the order may exist. Keep the key and try again later, for example from a background job. The first successful response tells you what happened.

## Coming from another gifting API

If you're migrating from another provider, you may already store an idempotency value per order. Tremendous calls it `external_id`, Tango Card calls it `externalRefID`, and Amazon Incentives calls it `creationRequestId`. All three send it as a field in the request body.

On Me takes it as the `Idempotency-Key` header instead, and the body has no equivalent field. Put the value you already generate in the header:

**Node**

```js
headers: {
  Authorization: `Bearer ${process.env.ONME_API_KEY}`,
  'Content-Type': 'application/json',
  'Idempotency-Key': existingExternalId, // the value you used to send as external_id
},
```

Two differences to keep in mind:

- A field you add to our body for this purpose is ignored, so it won't protect you. Only the header counts.
- Reusing a key with a different body is an error ([`idempotency_key_reused`](https://business.onme.com/docs/reference#errors/idempotency_key_reused)), not a silent replay of the old order. Make sure each value maps to exactly one order.
