unPLUGDevelopers

Idempotency

Retry any write after a timeout without doing it twice. Send an Idempotency-Key header, keep it with the operation, and send the same key on every retry.

Available at launch

A request that times out may or may not have run. Without a key, retrying a POST /v1/orders could credit the order twice. With a key, the second request returns the first response instead of running again.

The header#

POSTRequest
curl -X POST $UNPLUG_API/members/3f2b8c1e-6d0a-4e7b-9f3c-2a1d5e8b7c40/points_adjustments \
  -H "Authorization: Bearer $UNPLUG_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6b3f1d8e-4a2c-4e7b-9d05-8c1a3f6e2b94" \
  -d '{ "points": 50, "reason": "goodwill", "note": "Cold latte at South Congress" }'
  • Any string up to 255 characters. Use a UUID v4.
  • One key per operation, not per HTTP attempt. Every retry of the same operation sends the same key and the same body.
  • Keys are kept for 24 hours, per secret key (or per member, for member tokens) and per endpoint. The same key on two different endpoints is two different keys.

Where it's required#

EndpointIdempotency-Key
POST /v1/membersRequired
POST /v1/members/{id}/points_adjustmentsRequired
POST /v1/redemptionsRequired
POST /v1/ordersRequired
POST /v1/orders/{id}/refundsRequired
POST /v1/offers/{id}/grants Coming soonRequired
POST /v1/challenges/{id}/enrollments Coming soonRequired
POST /v1/member_tokensOptional
Every other POSTOptional, and honored when sent

GET, PATCH and DELETE don't take a key. Reading is always safe to repeat, and sending the same PATCH twice leaves the same result.

What happens on a retry#

You sendYou get
Same key, same body, first request finishedThe original status and body, with the header Idempotent-Replayed: true. Nothing runs again.
Same key, same body, first request still running409 request_in_progress. Wait a second and retry with the same key.
Same key, different body422 idempotency_key_reused. Nothing runs. Your code changed the request between attempts; fix that.
No key where one is required400 idempotency_key_required.
Same key after 24 hoursTreated as a new request.
Response headers on a replay201
Content-Type: application/json
Idempotent-Replayed: true
Request-Id: req_3Lp8Wm2vQz

Orders after 24 hours#

Orders have a second guard that never expires: external_order_id is unique in your brand forever. If a background job retries an order days later, even with a new key, you get 409 order_already_exists with the existing order_id instead of a second credit. Treat that as success.

The other endpoints have no such guard after 24 hours. Before retrying an old operation, check its result first: read the member's activity before redoing an adjustment, or the order before redoing a refund.

Generating and storing keys#

  1. Create the key with the operation. When your system creates the order, refund or adjustment record, generate a UUID v4 and save it in the same database write.
  2. Send the stored key. Every attempt, from the request handler or a background job, reads the key from the record. Never generate a new key inside a retry loop.
  3. Keep the body stable. Build the body from the stored record, so a retry sends exactly what the first attempt sent. Don't put a fresh timestamp in it.
  4. Record the outcome. Save the unPLUG id from the response on the record, so you know the operation is done and don't retry it.

Warning A new key on a retry turns it into a new request. For points adjustments that means the member gets the points twice.

Retry recipe#

Retry network errors, timeouts, 429, 5xx and 409 request_in_progress, with the same key, using exponential backoff with full jitter. Honor Retry-After when it is present. Stop on any other 4xx.

JavaScript (Node 18+)
const API = "https://api.unplugdining.com/v1";

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

// Full jitter: a random wait between 0 and min(cap, base * 2^attempt).
function backoff(attempt, baseMs = 500, capMs = 20_000) {
  return Math.random() * Math.min(capMs, baseMs * 2 ** attempt);
}

function isRetryable(status, code) {
  return status === 429 || status >= 500 || code === "request_in_progress";
}

export async function postWithRetry(path, body, idempotencyKey, maxAttempts = 6) {
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    let res;
    try {
      res = await fetch(`${API}${path}`, {
        method: "POST",
        headers: {
          Authorization: `Bearer ${process.env.UNPLUG_KEY}`,
          "Content-Type": "application/json",
          "Idempotency-Key": idempotencyKey,
        },
        body: JSON.stringify(body),
        signal: AbortSignal.timeout(10_000),
      });
    } catch (err) {
      // Timeout or network error: the request may have run. Retry with the same key.
      if (attempt === maxAttempts - 1) throw err;
      await sleep(backoff(attempt));
      continue;
    }

    if (res.ok) return res.json();

    const problem = await res.json().catch(() => ({}));
    if (!isRetryable(res.status, problem.code) || attempt === maxAttempts - 1) {
      const err = new Error(`${res.status} ${problem.code ?? "unknown_error"}`);
      err.problem = problem;
      err.requestId = res.headers.get("Request-Id");
      throw err;
    }

    const retryAfter = Number(res.headers.get("Retry-After"));
    await sleep(retryAfter > 0 ? retryAfter * 1000 : backoff(attempt));
  }
}

Using it for an order, with the key stored on your order record:

JavaScript
try {
  const credited = await postWithRetry("/orders", order.unplugBody, order.idempotencyKey);
  await db.orders.update(order.id, { unplugOrderId: credited.id });
} catch (err) {
  if (err.problem?.code === "order_already_exists") {
    // Credited earlier, maybe more than 24 hours ago. Not an error.
    await db.orders.update(order.id, { unplugOrderId: err.problem.order_id });
  } else {
    throw err; // log err.requestId and fix the request
  }
}

Keep the total retry time well under the 15-minute hold of any redemption the order uses. If retries run out, queue the order and post it later; placed_at keeps the earning correct.