Idempotency

Most payment APIs accept an Idempotency-Key HTTP header so that retried requests don't create duplicate charges. Frame doesn't have one in V1. There's no middleware that inspects the header, no dedup table keyed on client-supplied UUIDs, no built-in protection at the transport layer.

This is a real gap. If your application retries a transfer create because of a network timeout and the original request actually landed, you'll create two transfers and double-charge the customer. The integrator has to handle the case explicitly.

The good news: each surface has a working pattern. The bad news: the patterns differ per surface, and you have to wire them yourself.

Billing events — reference is the dedup key

Billing metric events are the one place Frame does enforce idempotency. The reference field on POST /v1/billing/metering_events is a globally-unique key that Frame stores with a uniqueness constraint:

curl --request POST \
  --url https://api.framepayments.com/v1/billing/metering_events \
  --header "Authorization: Bearer $FRAME_SECRET_KEY" \
  --data '{
    "customer": "<customer_id>",
    "event_name": "api_call",
    "reference": "req_8f3a2c1e_2026-06-05T14:23:00Z",
    "value": 1
  }'

Same reference value on a retry returns a duplicate_reference signal rather than logging the event twice. This is the only API surface in Frame V1 with safe-replay built in.

Derive the reference deterministically from your business logic — request ID, idempotency key, a composite of customer + timestamp + action. Whatever you pick, ensure the same logical event always produces the same reference, even across retries / app restarts / queue redelivery.

See metering for the deeper model.

Transfers and charges — query-before-retry

For POST /v1/transfers (and other money-movement endpoints), there's no client-supplied idempotency key. The safe pattern when you need to retry after a timeout:

  1. Don't retry blindly. A blind retry on an ambiguous outcome can double-charge.
  2. Query first. GET /v1/charges?account_id=<id> (or scoped equivalent) — look for a recent charge matching the amount + payment method + account that you were about to create.
  3. Retry only if no recent match. If a charge from the past few minutes matches your intended request, treat the original as successful and use that record. Otherwise, retry.
async function safeCreateTransfer({ accountId, paymentMethodId, amountCents }) {
  try {
    return await frame.transfers.create({ ... });
  } catch (err) {
    // Network timeout — outcome ambiguous.
    if (isTimeoutError(err)) {
      const recent = await frame.charges.list({
        account_id: accountId,
        // ...filter by recency + match criteria
      });
      const match = recent.find(c =>
        c.amount_cents === amountCents &&
        c.payment_method_id === paymentMethodId &&
        Date.now() - new Date(c.created).getTime() < 5 * 60 * 1000
      );
      if (match) return match;  // Original landed; use it.
      // No match — safe to retry.
      return await frame.transfers.create({ ... });
    }
    throw err;
  }
}

This pattern doesn't generalize perfectly — if the customer made another charge of the same amount in the past 5 minutes, you'll false-positive. Tighten the match criteria (include a metadata field with your idempotency UUID, then look for it on returned charges) for production-quality recovery.

Webhook handlers — dedupe on event ID

Inbound webhook deliveries can repeat. Frame retries failed deliveries up to 3 times (see events and webhooks), so your handler will sometimes see the same event twice — once from the original delivery your endpoint timed out on, again from the retry.

Dedupe handler-side by tracking processed event IDs:

async function handleWebhook(event) {
  const alreadyProcessed = await db.events.exists({ frame_event_id: event.id });
  if (alreadyProcessed) {
    return { status: 'ok', deduped: true };
  }

  await db.transaction(async tx => {
    await tx.events.insert({ frame_event_id: event.id, ... });
    await applyBusinessLogic(event);
  });
}

This is the standard webhook-handling pattern; nothing Frame-specific. The Frame event payload includes a unique id that's stable across retries.

Subscription renewals and invoice charges

Subscription renewals charge automatically each cycle; if a charge fails, Frame doesn't auto-retry subscription-renewal invoices. Your application orchestrates recovery (notify the customer, update payment method, re-issue the invoice).

Billing-pipeline invoices (those generated via POST /v1/billing/billing_invoice) do auto-retry — Billings::RetryFailedChargesJob runs daily at 2 AM and retries failed billing charges up to 3 attempts within 72 hours. See build postpaid billing for the recovery semantics.

Metadata as a client-side idempotency tracker

A pragmatic workaround for endpoints without dedup: pass your own UUID in the request's metadata field, then query by that metadata on retry:

# First attempt
curl --request POST \
  --url https://api.framepayments.com/v1/transfers \
  --data '{
    "amount": 5000,
    "currency": "USD",
    "account_id": "<account_id>",
    "payment_method_id": "<payment_method_id>",
    "metadata": { "idem_key": "<your_uuid>" }
  }'

# On timeout, before retrying: query for the UUID
curl --request GET \
  --url "https://api.framepayments.com/v1/charges?account_id=<account_id>" \
  --header "Authorization: Bearer $FRAME_SECRET_KEY"
# ...iterate, check metadata for { idem_key: "<your_uuid>" }

If you find a record with the UUID, the original landed — use it. If not, retry safely.

The downside: metadata is limited (20 entries per object, 40-char keys, 100-char values), so reserve one key for the idempotency UUID and never reuse it for anything else.

Why Frame doesn't have a header-based key (yet)

It's a real gap in V1, not a deliberate design choice. Adding Idempotency-Key middleware is a candidate for V2 — it would let merchants pass a UUID per request and Frame would store the response keyed on it, returning the cached response on retry instead of re-executing. Until then, the patterns above are the workarounds.

If your platform is materially affected by the gap (high transfer volume + unreliable network paths), contact Frame support — there may be lower-level processor-side guarantees that help.

Gotchas

Symptom: you retried a transfer create after a timeout and ended up with two charges. Why: there's no built-in dedup on the transfer surface, and blind retry duplicated the request. Fix: implement the query-before-retry pattern above. Refund the duplicate.

Symptom: your billing events keep getting duplicate_reference responses even though you meant to log distinct events. Why: your reference derivation isn't producing unique values per event — probably collapsing to the same value when you intended otherwise (e.g., using just customer_id without a timestamp). Fix: include enough variability in the reference to uniquely identify each logical event (request ID, timestamp at millisecond precision, action+entity composite).

Symptom: your webhook handler processed the same event twice and applied a side effect twice. Why: Frame retries failed deliveries; your handler isn't deduping. Fix: implement the event-ID dedup pattern above. The event's id is stable across retries.

Reference

For the only built-in idempotency primitive, see POST/v1/billing/metering_events. For the recovery patterns on charge surfaces, see transfers + handle declines.