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:
- Don't retry blindly. A blind retry on an ambiguous outcome can double-charge.
- 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. - 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.