# Error reference Every error response from the Ledger API uses `application/problem+json`, the format defined in [RFC 9457][rfc9457]. Every entry on this page states one cause and one fix, because an error page that lists six possible causes is a page that has moved the diagnosis back onto you. ## Read this first **Branch on `code`, not on `title`, `detail`, or the HTTP status.** `code` is part of the contract and will not change for a given condition. `title` and `detail` are prose written for humans and may be reworded in any release. Several distinct `code` values share a single status, so the status alone is never enough to decide what to do. **A declined payment is not an error.** `POST /v1/payments` returns `201` when the attempt was made, whatever the commercial outcome. A decline produces a `Payment` with `status: failed` and a populated `failure_code`. If your error handler is catching declines, it is catching the wrong thing. See [Payment failures](#payment-failures). **Quote `request_id` when you contact support.** It appears on every error body and identifies the exact request in our logs. Without it, a support conversation starts with an hour of log archaeology. ### Anatomy of an error body ```json { "type": "https://docs.ledgerapi.dev/errors/validation_failed", "title": "Validation failed", "status": 422, "code": "validation_failed", "detail": "One or more fields are invalid.", "instance": "/v1/invoices", "request_id": "req_8Kd2PqL4vN", "errors": [ { "field": "/line_items/2/unit_amount", "code": "below_minimum", "detail": "unit_amount must be greater than or equal to 0." } ] } ``` `field` is a [JSON Pointer][rfc6901] into your request body. Validation failures report **every** invalid field, not just the first, so a form can be corrected in a single pass. --- ## Retry policy at a glance Retrying the wrong error wastes quota and delays recovery. Retrying the right error without an idempotency key duplicates money movement. | Status | Retry? | Conditions | |---|---|---| | `401`, `403` | No | The credential is wrong or under-scoped. Retrying cannot change that. | | `404` | Rarely | Only while a create you have not yet confirmed may still be committing. | | `409` `idempotency_in_progress` | Yes | After a short delay. The original is still running. | | `409` `idempotency_key_reused` | No | Caller bug. Fix key generation. | | `412` | Yes | Re-read the resource, re-apply your change, resend with the new `ETag`. | | `422` | No | The request is semantically wrong. Change it first. | | `428` | Yes | Read the resource, take its `ETag`, resend with `If-Match`. | | `429` | Yes | After `Retry-After` seconds, with exponential backoff and jitter. | | `5xx` | Yes | **With the same `Idempotency-Key`.** See [5xx on a write](#5xx-on-a-write-means-unknown-not-failed). | --- ## 401 Unauthorized ### `invalid_api_key` **What happened.** The key in the `Authorization` header is not recognized. **Why it happens.** Almost always one of three things: the key was revoked, the header is malformed, or, by far the most common, a `sk_test_` key was sent to `api.ledgerapi.dev` or a `sk_live_` key to `api.sandbox.ledgerapi.dev`. **Fix.** Confirm the key prefix matches the host. Confirm the header is exactly `Authorization: Bearer sk_...` with a single space and no quotes. If the key is genuinely revoked, issue a new one; revocation is not reversible. > The environment mismatch returns `401` rather than silently routing to the > other environment. That is deliberate: it turns the most expensive > configuration mistake in payments into a loud failure on your first call > instead of a quiet one at settlement. **Retry?** No, not without changing the credential. --- ## 403 Forbidden ### `insufficient_scope` **What happened.** The key is valid but does not carry the privilege this operation requires. **Why it happens.** Restricted keys are commonly issued read-only for analytics or reporting workloads, then reused for a write path. **Fix.** Use a key with the required scope, or widen the scope of the existing key. Do not widen a key that is deployed somewhere you would not want writes to originate from. **Retry?** No. --- ## 404 Not Found ### `resource_not_found` **What happened.** No resource with that identifier is visible to this credential. **Why it happens.** A typo in the identifier, an identifier from the other environment, or a resource that belongs to a different account. **Fix.** Check the identifier and the environment. > This status is also what you receive when the resource exists but belongs to > someone else. Returning `403` there would confirm that the identifier is > real, which is enough to enumerate the identifier space. The ambiguity is > intentional and will not be removed. **Retry?** No, unless you are racing a create whose response you have not yet confirmed. After a `DELETE`, a `404` on a retry means the first attempt succeeded. --- ## 409 Conflict Both conflicts concern `Idempotency-Key`. They look similar and need opposite responses, so check `code`. ### `idempotency_in_progress` **What happened.** A request with this key is still being processed. **Why it happens.** A client retried before the original response arrived, usually because its timeout is shorter than the server's processing time. **Fix.** Wait and retry with the same key. You will receive either the stored original response or a fresh result. Consider raising your client timeout above the 95th-percentile latency for the endpoint. **Retry?** Yes, after a short delay with backoff. ### `idempotency_key_reused` **What happened.** This key was already used with a **different** request body. **Why it happens.** The key is being generated once per session, per customer, or per day rather than once per logical operation. Reusing a key across two different operations is the single most common idempotency bug. **Fix.** Generate a fresh key, with at least 128 bits of entropy, at the moment the operation is decided, not at the moment the HTTP call is made. All retries of that one operation share that one key; nothing else does. **Retry?** No. Retrying with the same key returns the same conflict. Fix key generation. --- ## 412 Precondition Failed and 428 Precondition Required ### `precondition_required` (428) **What happened.** The operation requires `If-Match` and none was sent. **Fix.** `GET` the resource, take the `ETag` from the response headers, and resend with `If-Match: ""`. Include the quotes; the `ETag` value includes them. **Retry?** Yes, with the header. ### `precondition_failed` (412) **What happened.** Your `If-Match` value does not match the resource's current `ETag`. **Nothing was written.** **Why it happens.** Someone else modified the invoice between your read and your write: another operator, another worker, or a scheduled job. **Fix.** Re-read the resource, re-apply your change to the current version, and resend with the new `ETag`. Do not strip `If-Match` to force the write through; that converts a caught conflict into a silent overwrite of someone else's edit. **Retry?** Yes, after re-reading. --- ## 422 Unprocessable Content `422` means the JSON parsed but the request is semantically wrong. Two families: field validation, and operations the resource's current state does not allow. ### `validation_failed` **What happened.** One or more fields failed validation against the schema: a required field is missing, a value is out of range, or a value has the wrong format. The `errors` array names every one. **Fix.** Correct the fields identified by the JSON Pointers in `errors[].field`. **Retry?** Not until the body changes. ### `unsupported_currency` **What happened.** The `currency` value is a well-formed ISO 4217 code that Ledger does not settle in. A value that is not three uppercase letters fails schema validation instead and returns `validation_failed`. **Fix.** Use a supported code. `currency` cannot be changed after an invoice is created, so to bill in a different currency, create a new invoice. **Retry?** No, not with the same currency. ### `invoice_not_editable` **What happened.** You attempted to change an invoice that is no longer a `draft`: an update or a delete on an invoice that has been finalized, or a finalize on one that is already `paid`, `void`, or `uncollectible`. **Why it happens.** The invoice was finalized between your read and your write, or a retry ran after a successful finalize. **Fix.** A finalized invoice is a legal document in most jurisdictions and its line items are frozen by design. Correct it by voiding it and issuing a replacement, or by issuing a credit note. Do not attempt to edit around this. **Retry?** No. The invoice will not return to `draft`. ### `invoice_has_payments` **What happened.** You attempted to void an invoice that has at least one `succeeded` payment. **Fix.** Refund the payments first, then void. The order matters: voiding an invoice with captured funds against it would leave money in the account with no document explaining it, which is precisely what an auditor looks for. **Retry?** Only after the refunds have succeeded. ### `invoice_not_open` **What happened.** You attempted to pay an invoice that is not `open`. Only an `open` invoice is collectible. **Fix.** Finalize a `draft` first. A `paid`, `void`, or `uncollectible` invoice is terminal and cannot be paid. **Retry?** Only after finalizing a draft. A terminal invoice never becomes payable. ### `payment_not_refundable` **What happened.** You attempted to refund a payment that is not `succeeded`. **Why it happens.** The payment is still `processing`, or it `failed` and no funds were captured. A refund needs captured funds to return. **Fix.** Refund only after `payment.succeeded`. A `failed` payment needs no refund, because nothing was taken from the payer. **Retry?** Only once the payment has succeeded. ### `refund_amount_exceeded` **What happened.** The requested refund is larger than the payment's remaining refund headroom, `amount - amount_refunded - amount_refund_pending`. **Why it happens.** Usually a second refund issued while a first is still `processing`. Refunds in flight reserve their amount, so the second request is measured against what is left after the first, not against the full payment. On releases before `2026-09-01` the same pair was accepted, and could refund more than the payment. **Fix.** Read the payment. If `amount_refund_pending` is zero, the request exceeds what can ever be refunded; reduce the amount. If it is not zero, the request fits only if a pending refund fails and releases its reservation. Wait for `refund.failed` before trying again, not for a timer. **Retry?** Not on a schedule. Only after a `refund.failed` event for the same payment. --- ## 429 Too Many Requests ### `rate_limit_exceeded` **What happened.** Quota for the current window is exhausted. **Fix.** Wait `Retry-After` seconds, then retry with **exponential backoff and jitter**. The jitter is not optional at scale: without it, every throttled worker retries on the same tick and re-exhausts the window immediately, which is how a brief throttle becomes a sustained outage. Read the `RateLimit` and `RateLimit-Policy` response fields to pace yourself before you are throttled rather than after. These follow [`draft-ietf-httpapi-ratelimit-headers`][ratelimit-draft], which is an Internet-Draft rather than a published RFC. Depend on `Retry-After`, which is standardized in [RFC 9110][rfc9110], and treat the other two as advisory. **Retry?** Yes, with backoff. --- ## 5xx Server errors ### `internal_error` **What happened.** The request failed on our side. #### 5xx on a write means *unknown*, not *failed* This is the most consequential sentence on this page. A `5xx` on `createPayment` does not tell you the payment did not happen. The request may have committed before the failure and lost only its response. **Fix.** Retry with the **same** `Idempotency-Key`. If the original committed, you receive the stored response with `Idempotent-Replay: true` and no second payment is created. If it did not, the retry processes normally. Retrying a write after a `5xx` with a *new* idempotency key is how duplicate charges are created. **Retry?** Yes, with the same key, with backoff. --- ## Payment failures These are **not** HTTP errors. They arrive as `failure_code` on a `Payment` with `status: failed`, returned with `201`. | `failure_code` | Means | What to tell the payer | Retryable | |---|---|---|---| | `insufficient_funds` | The account lacked the available balance | Use another method or try again later | Yes, later | | `card_expired` | The card's expiry date has passed | Use a current card | Only with a new card | | `incorrect_details` | The card number, expiry date, or security code was wrong | Re-enter the details | Only after correction | | `address_mismatch` | The billing address does not match the issuer's record | Enter the address the bank holds | Only after correction | | `card_declined` | The issuer declined without a specific reason | Contact the bank or use another method | Rarely succeeds | | `do_not_honor` | The issuer's generic refusal, sent without a reason | Contact the bank | Rarely succeeds | | `card_not_supported` | The issuer blocks this type of payment on this card | Ask the bank to lift the block, or use another card | Only after the block is lifted | | `withdrawal_limit_exceeded` | Over a per-transaction, daily, or monthly limit | Try again after the limit resets, or pay by transfer | Yes, after the reset | | `card_lost_or_stolen` | The card has been reported lost or stolen | **Show the `card_declined` message** (rule 2) | Never | | `processor_unavailable` | The payment network could not be reached | Nothing was charged; try again in a few minutes | Yes, with backoff | The payer-facing wording for each code is published in the [help center](/samples/payments-kb/decline-reasons). Use it as the source for your own copy so that your checkout and our help articles say the same thing. Three rules: 1. **Never show `failure_message` to the payer.** It is written in English for your support staff and its wording is not part of the contract. Map `failure_code` to your own localized copy. 2. **Never tell the payer a card was reported lost or stolen.** The person at your checkout may be the one who took it. Show the generic decline for `card_lost_or_stolen`, log the real code, and let your fraud process act on it. 3. **Never retry a decline on a fixed schedule.** Issuers treat repeated declines against the same card as an abuse signal and will begin declining attempts that would otherwise have succeeded. --- ## Reporting a documentation defect If a page here names a cause that is not the real cause, that is a bug of the same severity as a wrong code sample. Open an issue with the `request_id` and the `code`, and it will be treated as one. [rfc9457]: https://www.rfc-editor.org/rfc/rfc9457 [rfc6901]: https://www.rfc-editor.org/rfc/rfc6901 [rfc9110]: https://www.rfc-editor.org/rfc/rfc9110 [ratelimit-draft]: https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/