Skip to navigation

Idempotency and errors

Retry safely, and read what went wrong.

A billing API that gets a retry wrong charges twice. These rules make every write safe to repeat.

The Idempotency-Key header

Every POST that changes something, except the event and cost endpoints below, accepts an Idempotency-Key header, and so do PATCH and DELETE. Send a UUID, or a key from your own system such as reverse-oc_2xK9-ticket-118. The three read-only POST calls, POST /v1/config/validate, POST /v1/payment_providers/resolve and POST /v1/events/preview, change nothing and don’t need one. A request sent with Runledger-Dry-Run: true writes nothing, so its result isn’t stored against the key.

What happensResponse
First request with a keyProcessed as normal; the result is stored for 30 days
Same key, same body, after it finishedThe stored response, with Idempotent-Replayed: true
Same key while the first is still running409 with idempotency_key_in_use and retryable: true
Same key, different body422 with idempotency_key_reused
The first request failed before it took effectNot stored, so a retry with the same key runs again

Keys are scoped to your account, environment and endpoint. The SDKs generate a key for each call and reuse it across their own retries; pass your own when a retry may come from a different process, such as a job queue.

Event and cost keys

track(), trace() and their batch forms deduplicate on the idempotency_key field of each item, for 35 days, and don’t use the header. Build it from your own data so a replay from your queue lands on the same key:

await runledger.track({
idempotency_key: `${conversation.id}-resolved`,
name: "resolution.chat",
external_customer_id: org.id,
reference: conversation.id,
});

A repeat returns the original event. In a batch, each item reports created, duplicate or rejected on its own.

Fix a mistake in an event

An event is immutable once it is recorded. Its name (the event_name that rates and rules match) never changes, and there is no call to edit or delete an event. To fix a mistake, send a correcting event with its own idempotency_key, such as a signal that reverses an outcome charge that shouldn’t stand. The ledger works the same way: a correction is a new entry that links to the one it corrects.

Retries

The SDKs retry twice by default, with exponential backoff and jitter, on connection errors, 408, 429 and 5xx, and honour Retry-After. Errors with retryable: false won’t succeed on retry; fix the request first. If a write stays unresolved, stop retrying, then read the object or GET /v1/webhook_events to find out what happened.

The error body

Errors use RFC 9457 problem details, sent as application/problem+json:

{
"type": "https://docs.runledger.co/reference/idempotency-and-errors#error-codes",
"title": "Outcome charge locked",
"status": 409,
"detail": "oc_2xK9cL4vRt passed its reversal horizon on 2026-12-03T09:00:00Z.",
"code": "outcome_charge_locked",
"request_id": "req_7Hc2Lq0vTz8d",
"retryable": false,
"doc_url": "https://docs.runledger.co/reference/idempotency-and-errors#error-codes"
}

type and doc_url link to the error codes table below. Branch on code, which is stable. detail is for people and may change. param, when present, names the field at fault as a dotted path, such as events.3.timestamp. On insufficient_scope, required_scope names the scope the call needed, such as config:write. On an invalid_state that names objects in the way, blocked_by lists them as {object, id}: removing a payment provider lists the routing_rules that use it and the customers whose own payment_provider names it, in one list, and an Admin changes or deletes them first.

Status codes

StatusMeaning
400The request is malformed or a parameter is invalid
401The key is missing, invalid or revoked
402Valid, but the balance, credit limit or payment method can’t cover it
403The key lacks the scope (insufficient_scope), a live key called a sandbox-only endpoint, or a live config change needs a proposal (proposal_required)
404No such object in this environment
409The object’s state doesn’t allow this, or the same key is in flight
422The idempotency key was used with a different body
424The payment provider failed or refused
429Rate limited; wait for Retry-After
500, 503Runledger’s fault; retry with the same key

A check() denial is not an error. It returns 200 with allowed: false.

Error codes

codeStatusRetryableMeaning
invalid_request400NoThe request couldn’t be parsed
parameter_missing400NoA required parameter is missing
parameter_invalid400NoA parameter has the wrong type, format or value. For example a customer-set spend limit above its ceiling, a grant to a pool the plan doesn’t list, or both included_credits and allowances on one plan
parameter_unknown400NoThe request sent a parameter that doesn’t exist
livemode_mismatch400NoNot returned today: an ID from the other environment answers 404 with resource_not_found, since each environment sees only its own objects
authentication_failed401NoThe key is missing, invalid or revoked
permission_denied403NoThe key can’t do this, or the action needs a signed-in person, such as approving a config proposal. An agent key raising or removing a customer’s spend limit or auto top-up limit in live is refused with this code
insufficient_scope403NoThe key or session lacks a scope. required_scope names it
proposal_required403NoA live configuration change must go through POST /v1/config_proposals
config_invalid400NoA configuration document failed validation. The errors list says where. For example two equally specific cost model overrides whose dates overlap
resource_not_found404NoNo object with that ID
resource_already_exists409NoAn object with that external_id or key exists
idempotency_key_in_use409YesA request with this key is still running
invalid_state409NoThe object’s status doesn’t allow this action. For example setting an auto top-up for a customer with no saved payment method, approving a config proposal that is stale because the configuration changed after it was proposed, deleting a price rule that a plan rate still uses, replacing the configuration with PUT /v1/config and a stale if_match, or removing a payment provider that is the only live one, has open disputes, is the default, is used by a routing rule or is named by a customer’s own payment_provider
outcome_charge_locked409NoThe charge passed its reversal horizon
hold_not_active409NoThe hold was already captured, released or expired
plan_locked409NoThe plan has subscriptions, so its pricing can’t change
test_clock_advancing409YesWait for test_clock.ready
insufficient_balance402NoA hold extension doesn’t fit the available balance
credit_limit_exceeded402NoNot returned today: check() denies postpaid work past the credit limit with credit_limit_reached, and extending a hold past it is insufficient_balance
idempotency_key_reused422NoSame key, different body
provider_error424NoThe payment provider failed. Read detail, and try again once the cause is fixed
rate_limited429YesWait for Retry-After
api_error500YesSomething failed on Runledger’s side
service_unavailable503YesRunledger is briefly unavailable

New codes may be added. Treat an unknown code by its status.

See also