> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.runledger.co/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.runledger.co/_mcp/server.

# Idempotency and errors

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 happens                                   | Response                                                  |
| ---------------------------------------------- | --------------------------------------------------------- |
| First request with a key                       | Processed as normal; the result is stored for 30 days     |
| Same key, same body, after it finished         | The stored response, with `Idempotent-Replayed: true`     |
| Same key while the first is still running      | `409` with `idempotency_key_in_use` and `retryable: true` |
| Same key, different body                       | `422` with `idempotency_key_reused`                       |
| The first request failed before it took effect | Not 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:

```typescript
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](/concepts/outcome-settlement#what-a-reversal-does) 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`:

```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](#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_rule`s that use it and the `customer`s whose own `payment_provider` names it, in one list, and an Admin changes or deletes them first.

## Status codes

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

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

## Error codes

| `code`                    | Status | Retryable | Meaning                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| ------------------------- | ------ | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `invalid_request`         | 400    | No        | The request couldn't be parsed                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `parameter_missing`       | 400    | No        | A required parameter is missing                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `parameter_invalid`       | 400    | No        | A 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_unknown`       | 400    | No        | The request sent a parameter that doesn't exist                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `livemode_mismatch`       | 400    | No        | Not returned today: an ID from the other environment answers `404` with `resource_not_found`, since each environment sees only its own objects                                                                                                                                                                                                                                                                                                                                                                                           |
| `authentication_failed`   | 401    | No        | The key is missing, invalid or revoked                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `permission_denied`       | 403    | No        | The 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_scope`      | 403    | No        | The key or session lacks a scope. `required_scope` names it                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `proposal_required`       | 403    | No        | A live configuration change must go through `POST /v1/config_proposals`                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `config_invalid`          | 400    | No        | A configuration document failed validation. The `errors` list says where. For example two equally specific cost model overrides whose dates overlap                                                                                                                                                                                                                                                                                                                                                                                      |
| `resource_not_found`      | 404    | No        | No object with that ID                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `resource_already_exists` | 409    | No        | An object with that `external_id` or `key` exists                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `idempotency_key_in_use`  | 409    | Yes       | A request with this key is still running                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `invalid_state`           | 409    | No        | The 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_locked`   | 409    | No        | The charge passed its reversal horizon                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `hold_not_active`         | 409    | No        | The hold was already captured, released or expired                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `plan_locked`             | 409    | No        | The plan has subscriptions, so its pricing can't change                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `test_clock_advancing`    | 409    | Yes       | Wait for `test_clock.ready`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `insufficient_balance`    | 402    | No        | A hold extension doesn't fit the available balance                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `credit_limit_exceeded`   | 402    | No        | Not 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_reused`  | 422    | No        | Same key, different body                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `provider_error`          | 424    | No        | The payment provider failed. Read `detail`, and try again once the cause is fixed                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `rate_limited`            | 429    | Yes       | Wait for `Retry-After`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `api_error`               | 500    | Yes       | Something failed on Runledger's side                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `service_unavailable`     | 503    | Yes       | Runledger is briefly unavailable                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |

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

## See also

* [Conventions](/reference/conventions) covers versions, rate limits and request IDs.
* [Going live](/going-live/production-ready#send-events-through-your-queue) shows retries in a job queue.