Search

Search guides and the API reference

Sending gifts

Idempotency and retries

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

For AI agents: this page is available as Markdown, and every guide plus the API reference is in llms-full.txt.

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 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.

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 sendYou get
The same key and the same body as an order that was created200 OK with that order, plus an Idempotent-Replayed: true header. Nothing new is created or charged.
The same key with a different body409 idempotency_key_reused. The original order is unchanged.
The same key while the first request is still runningWe 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; retry shortly.
A key whose earlier requests all got error responsesThe 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 gotWhat it meansWhat to do
A 2xx responseThe 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.
A response with an error objectNothing happened: no order, no charge, no gifts.Follow the code's retry rule in the error reference. 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 objectThe 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.

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,
  })
}

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

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}`)
}

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
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), not a silent replay of the old order. Make sure each value maps to exactly one order.