openapi: 3.1.1 info: title: Ledger API version: '2026-09-01' summary: Invoicing, payment capture, and refunds for B2B billing. description: | The Ledger API is a resource-oriented HTTP API for issuing invoices, capturing payments against them, and refunding captured funds. ## Conventions - **Transport.** HTTPS only. Requests over HTTP are rejected at the edge; no redirect is issued, because redirecting a request that carries a credential would leak that credential to the plaintext hop. - **Encoding.** Request and response bodies are UTF-8 encoded JSON. - **Time.** All timestamps are RFC 3339 `date-time` values in UTC with a `Z` offset (for example, `2026-09-01T14:32:07Z`). - **Money.** All monetary values are represented by a `Money` object holding an integer `amount` in the currency's minor unit and an ISO 4217 `currency` code. See the `Money` schema for the zero-decimal and three-decimal currency rules. - **Identifiers.** Resource identifiers are opaque, prefixed strings (`inv_`, `pay_`, `ref_`, `cus_`). Do not parse them, infer ordering from them, or assume a fixed length beyond the documented maximum. ## Versioning The major version is pinned in the path (`/v1`). Backward-incompatible changes ship as a new dated release, which an integration opts into with the `Ledger-Version` header. Omitting the header pins the request to the release that was current when the account's first API call was made, so an unmaintained integration never breaks silently. Adding an optional request field, a response field, an enum member to a response, or a new operation or webhook is **not** treated as breaking. Additive changes reach every pinned release on the day they ship. Clients must ignore unrecognized response fields and must tolerate unrecognized enum members rather than failing closed. ## Errors Errors use `application/problem+json` as defined in RFC 9457. A declined payment is **not** an HTTP error; see the `Payment` schema. contact: name: Ledger API Support url: https://docs.ledgerapi.dev/support email: api-support@ledgerapi.dev license: name: MIT identifier: MIT servers: - url: https://api.ledgerapi.dev description: Production. Moves real money. - url: https://api.sandbox.ledgerapi.dev description: Sandbox. Identical surface, simulated settlement, no real funds. security: - secretKey: [] tags: - name: Invoices description: | An invoice is a request for payment. It moves through an explicit state machine: `draft` -> `open` -> `paid` | `void` | `uncollectible`. Only a `draft` invoice is mutable. - name: Payments description: | A payment is an attempt to move funds against an open invoice. Creating a payment always succeeds at the HTTP layer if the request is well formed; the outcome of the attempt is carried in `Payment.status`. - name: Refunds description: | A refund returns captured funds to the payer. Refunds are only valid against a payment in the `succeeded` state. paths: /v1/invoices: get: operationId: listInvoices summary: List invoices description: | Returns a page of invoices for the authenticated account, newest first by `created_at`. Pagination is cursor based; see the `cursor` parameter. tags: - Invoices parameters: - $ref: '#/components/parameters/LedgerVersion' - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' - name: status in: query required: false description: | Return only invoices in this state. Omit to return all states. schema: $ref: '#/components/schemas/InvoiceStatus' - name: customer_id in: query required: false description: Return only invoices billed to this customer. schema: type: string maxLength: 64 pattern: '^cus_[A-Za-z0-9]{1,58}$' responses: '200': description: A page of invoices. headers: RateLimit: $ref: '#/components/headers/RateLimit' RateLimit-Policy: $ref: '#/components/headers/RateLimitPolicy' content: application/json: schema: $ref: '#/components/schemas/InvoiceList' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '422': $ref: '#/components/responses/UnprocessableContent' '429': $ref: '#/components/responses/TooManyRequests' '500': $ref: '#/components/responses/InternalServerError' post: operationId: createInvoice summary: Create a draft invoice description: | Creates an invoice in the `draft` state. A draft invoice is not visible to the customer, accrues no dunning, and can be edited with `updateInvoice` or removed with `deleteInvoice`. Call `finalizeInvoice` to issue it. This operation is not idempotent by HTTP method, so an `Idempotency-Key` header is **required**. See the header description for replay and conflict semantics. tags: - Invoices parameters: - $ref: '#/components/parameters/LedgerVersion' - $ref: '#/components/parameters/IdempotencyKeyRequired' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/InvoiceCreateRequest' example: customer_id: cus_9ZQx4Kp2Lm currency: INR due_at: '2026-10-01T00:00:00Z' line_items: - description: Platform subscription, September 2026 quantity: 1 unit_amount: 4500000 - description: Overage, 12 GB egress quantity: 12 unit_amount: 85000 responses: '201': description: | The invoice was created. The `Location` header carries the canonical URL of the new resource. headers: Location: $ref: '#/components/headers/Location' ETag: $ref: '#/components/headers/ETag' Idempotent-Replay: $ref: '#/components/headers/IdempotentReplay' RateLimit: $ref: '#/components/headers/RateLimit' content: application/json: schema: $ref: '#/components/schemas/Invoice' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '409': $ref: '#/components/responses/IdempotencyConflict' '422': $ref: '#/components/responses/UnprocessableContent' '429': $ref: '#/components/responses/TooManyRequests' '500': $ref: '#/components/responses/InternalServerError' /v1/invoices/{invoice_id}: parameters: - $ref: '#/components/parameters/InvoiceId' - $ref: '#/components/parameters/LedgerVersion' get: operationId: getInvoice summary: Retrieve an invoice description: | Returns a single invoice. The response carries an `ETag`, which you must supply as `If-Match` when updating the invoice. tags: - Invoices responses: '200': description: The invoice. headers: ETag: $ref: '#/components/headers/ETag' RateLimit: $ref: '#/components/headers/RateLimit' content: application/json: schema: $ref: '#/components/schemas/Invoice' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/TooManyRequests' '500': $ref: '#/components/responses/InternalServerError' patch: operationId: updateInvoice summary: Update a draft invoice description: | Applies a partial update to a `draft` invoice. Attempting to update an invoice in any other state returns `422` with the `invoice_not_editable` error code. `If-Match` is **required**. Send the `ETag` from your most recent read of the invoice. If the invoice has changed since that read, the request is rejected with `412` and nothing is written. This makes concurrent edits from two operators fail loudly instead of silently overwriting one another. tags: - Invoices parameters: - $ref: '#/components/parameters/IfMatchRequired' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/InvoiceUpdateRequest' example: due_at: '2026-10-15T00:00:00Z' responses: '200': description: The updated invoice. headers: ETag: $ref: '#/components/headers/ETag' RateLimit: $ref: '#/components/headers/RateLimit' content: application/json: schema: $ref: '#/components/schemas/Invoice' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '412': $ref: '#/components/responses/PreconditionFailed' '422': $ref: '#/components/responses/UnprocessableContent' '428': $ref: '#/components/responses/PreconditionRequired' '429': $ref: '#/components/responses/TooManyRequests' '500': $ref: '#/components/responses/InternalServerError' delete: operationId: deleteInvoice summary: Delete a draft invoice description: | Permanently deletes a `draft` invoice. A draft has no `invoice_number`, so deleting it leaves no gap in the sequence. Only a draft can be deleted. An invoice in any other state returns `422` with `invoice_not_editable`; cancel an `open` invoice with `voidInvoice` instead, which keeps it on record. Deleting is idempotent by method. Repeating the call after a successful delete returns `404`, which a retry loop should treat as confirmation rather than failure. tags: - Invoices responses: '204': description: The draft was deleted. There is no response body. headers: RateLimit: $ref: '#/components/headers/RateLimit' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '422': $ref: '#/components/responses/UnprocessableContent' '429': $ref: '#/components/responses/TooManyRequests' '500': $ref: '#/components/responses/InternalServerError' /v1/invoices/{invoice_id}/finalize: post: operationId: finalizeInvoice summary: Finalize a draft invoice description: | Transitions an invoice from `draft` to `open`, freezing its line items and assigning a sequential, gapless `invoice_number` for the account's current financial year. Finalizing is the point at which the invoice becomes a legal document in most jurisdictions. After this call the line items cannot be edited; correct a finalized invoice by voiding it and issuing a new one, or by issuing a credit note. This operation is effectively idempotent by state: calling it on an invoice that is already `open` returns `200` with the unchanged invoice rather than an error. Calling it on a `paid`, `void`, or `uncollectible` invoice returns `422` with `invoice_not_editable`. tags: - Invoices parameters: - $ref: '#/components/parameters/InvoiceId' - $ref: '#/components/parameters/LedgerVersion' - $ref: '#/components/parameters/IdempotencyKeyOptional' responses: '200': description: The finalized invoice. headers: ETag: $ref: '#/components/headers/ETag' RateLimit: $ref: '#/components/headers/RateLimit' content: application/json: schema: $ref: '#/components/schemas/Invoice' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/IdempotencyConflict' '422': $ref: '#/components/responses/UnprocessableContent' '429': $ref: '#/components/responses/TooManyRequests' '500': $ref: '#/components/responses/InternalServerError' /v1/invoices/{invoice_id}/void: post: operationId: voidInvoice summary: Void an open invoice description: | Transitions an `open` invoice to `void`. A voided invoice is cancelled and will not be collected, but it is never deleted and its `invoice_number` is never reused, because reusing or removing a number would break the gapless sequence that auditors rely on. An invoice with any `succeeded` payment against it cannot be voided; refund the payments first. That attempt returns `422` with `invoice_has_payments`. tags: - Invoices parameters: - $ref: '#/components/parameters/InvoiceId' - $ref: '#/components/parameters/LedgerVersion' - $ref: '#/components/parameters/IdempotencyKeyOptional' requestBody: required: false content: application/json: schema: $ref: '#/components/schemas/InvoiceVoidRequest' example: reason: duplicate responses: '200': description: The voided invoice. headers: ETag: $ref: '#/components/headers/ETag' RateLimit: $ref: '#/components/headers/RateLimit' content: application/json: schema: $ref: '#/components/schemas/Invoice' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/IdempotencyConflict' '422': $ref: '#/components/responses/UnprocessableContent' '429': $ref: '#/components/responses/TooManyRequests' '500': $ref: '#/components/responses/InternalServerError' /v1/payments: post: operationId: createPayment summary: Create a payment against an invoice description: | Attempts to capture funds against an `open` invoice. **A declined payment is a `201`, not a `4xx`.** The HTTP status describes whether the API understood and accepted your request; it does not describe the commercial outcome of the attempt. A card decline, an insufficient-funds response, or a bank rejection all produce a `Payment` resource in the `failed` state with `failure_code` and `failure_message` populated. Branch on `status`, never on the status code, to decide what to tell the payer. Some payment methods settle asynchronously. Those return a payment in the `processing` state; the terminal outcome arrives on the `payment.succeeded` or `payment.failed` webhook. Build on the webhooks, and use `getPayment` to reconcile a payment whose event you suspect you missed, not as a polling loop. tags: - Payments parameters: - $ref: '#/components/parameters/LedgerVersion' - $ref: '#/components/parameters/IdempotencyKeyRequired' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/PaymentCreateRequest' example: invoice_id: inv_7TgH2ReQvA amount: amount: 5520000 currency: INR payment_method_id: pm_4KdN8xWs1B responses: '201': description: | The attempt was made. Inspect `status` for the outcome; `succeeded`, `processing`, and `failed` are all returned with this status code. headers: Location: $ref: '#/components/headers/Location' Idempotent-Replay: $ref: '#/components/headers/IdempotentReplay' RateLimit: $ref: '#/components/headers/RateLimit' content: application/json: schema: $ref: '#/components/schemas/Payment' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/IdempotencyConflict' '422': $ref: '#/components/responses/UnprocessableContent' '429': $ref: '#/components/responses/TooManyRequests' '500': $ref: '#/components/responses/InternalServerError' /v1/payments/{payment_id}: parameters: - $ref: '#/components/parameters/PaymentId' - $ref: '#/components/parameters/LedgerVersion' get: operationId: getPayment summary: Retrieve a payment description: | Returns a single payment, including its refund totals. Read `amount_refunded` and `amount_refund_pending` here before deciding whether a further refund fits. Payment outcomes are delivered by webhook. Retrieve a payment to reconcile one whose event you suspect you missed, not in a polling loop. tags: - Payments responses: '200': description: The payment. headers: RateLimit: $ref: '#/components/headers/RateLimit' content: application/json: schema: $ref: '#/components/schemas/Payment' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/TooManyRequests' '500': $ref: '#/components/responses/InternalServerError' /v1/payments/{payment_id}/refunds: post: operationId: createRefund summary: Refund a payment description: | Returns funds to the payer. Only a `succeeded` payment can be refunded; any other state returns `422` with `payment_not_refundable`. Omit `amount` to refund everything not already refunded or pending. A refund is accepted only if its amount fits within the payment's refund headroom, `amount - amount_refunded - amount_refund_pending`. Refunds still `processing` count against that headroom, and the check and the reservation are a single atomic step, so two concurrent requests cannot both claim the same amount. A request that does not fit is rejected with `422` and `refund_amount_exceeded`. If a pending refund ends `failed`, its amount is released and can be refunded again. This is the behavior from release 2026-09-01. Earlier releases did not count `processing` refunds, so concurrent partial refunds could together exceed the payment. The release notes and the 2026-09-01 migration guide describe the change. Refunding the full amount of the only payment on an invoice does not reopen the invoice. Its state remains `paid`. tags: - Refunds parameters: - $ref: '#/components/parameters/PaymentId' - $ref: '#/components/parameters/LedgerVersion' - $ref: '#/components/parameters/IdempotencyKeyRequired' requestBody: required: false content: application/json: schema: $ref: '#/components/schemas/RefundCreateRequest' example: amount: amount: 1020000 currency: INR reason: requested_by_customer responses: '201': description: The refund was accepted. Inspect `status` for the outcome. headers: Location: $ref: '#/components/headers/Location' Idempotent-Replay: $ref: '#/components/headers/IdempotentReplay' RateLimit: $ref: '#/components/headers/RateLimit' content: application/json: schema: $ref: '#/components/schemas/Refund' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/IdempotencyConflict' '422': $ref: '#/components/responses/UnprocessableContent' '429': $ref: '#/components/responses/TooManyRequests' '500': $ref: '#/components/responses/InternalServerError' /v1/refunds/{refund_id}: parameters: - $ref: '#/components/parameters/RefundId' - $ref: '#/components/parameters/LedgerVersion' get: operationId: getRefund summary: Retrieve a refund description: | Returns a single refund. `status` says whether it has reached a terminal state, and `settled_at` says when. The `refund.succeeded` and `refund.failed` webhooks deliver the same transition without a request. Retrieve a refund when you need its current state on demand, for example to confirm that one refund has finished before issuing the next against the same payment. tags: - Refunds responses: '200': description: The refund. headers: RateLimit: $ref: '#/components/headers/RateLimit' content: application/json: schema: $ref: '#/components/schemas/Refund' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/TooManyRequests' '500': $ref: '#/components/responses/InternalServerError' webhooks: invoice.finalized: post: operationId: onInvoiceFinalized summary: An invoice moved from draft to open description: | Sent when an invoice is finalized. Verify the signature before parsing the body; see the `WebhookEvent` schema. tags: - Invoices requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/InvoiceEvent' responses: '2XX': description: | Return any 2xx status within 10 seconds to acknowledge receipt. Any other status, or a timeout, is treated as a failed delivery and retried with exponential backoff for 72 hours. invoice.paid: post: operationId: onInvoicePaid summary: An invoice was paid in full description: | Sent when the sum of `succeeded` payments against an invoice reaches its total. This event is the correct trigger for provisioning; do not provision on `createPayment` returning `processing`. tags: - Invoices requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/InvoiceEvent' responses: '2XX': description: Acknowledged. invoice.voided: post: operationId: onInvoiceVoided summary: An open invoice was voided description: | Sent when an invoice moves from `open` to `void`. The invoice in `data` carries `void_reason`. tags: - Invoices requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/InvoiceEvent' responses: '2XX': description: Acknowledged. payment.succeeded: post: operationId: onPaymentSucceeded summary: A payment attempt captured funds description: | Sent when a payment reaches `succeeded`, whether it was created in that state or left `processing` later. A payment that completes its invoice also produces `invoice.paid`; provision on that event, not on this one. tags: - Payments requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/PaymentEvent' responses: '2XX': description: Acknowledged. payment.failed: post: operationId: onPaymentFailed summary: A payment attempt reached a terminal failure description: | Sent when a payment reaches `failed`, whether it was created in that state or left `processing` later. The invoice remains `open` and collectible. tags: - Payments requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/PaymentEvent' responses: '2XX': description: Acknowledged. refund.succeeded: post: operationId: onRefundSucceeded summary: A refund returned funds to the payer description: | Sent when a refund leaves `processing` for `succeeded`. Its amount moves from `amount_refund_pending` to `amount_refunded` on the payment. tags: - Refunds requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/RefundEvent' responses: '2XX': description: Acknowledged. refund.failed: post: operationId: onRefundFailed summary: A refund reached a terminal failure description: | Sent when a refund leaves `processing` for `failed`. No funds moved, and the amount it reserved is released back to the payment's refund headroom. tags: - Refunds requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/RefundEvent' responses: '2XX': description: Acknowledged. components: securitySchemes: secretKey: type: http scheme: bearer bearerFormat: 'sk_live_<43 characters> or sk_test_<43 characters>' description: | Authenticate with an account secret key in the `Authorization` header: ``` Authorization: Bearer sk_test_51Hq... ``` Secret keys carry full account privileges. They belong on a server, in a secret manager, and never in a mobile binary, a single-page application bundle, or a repository. A key committed to source control must be rotated, not deleted from history; assume it was scraped within seconds of the push. Keys prefixed `sk_test_` are accepted only by the sandbox host, and `sk_live_` only by the production host. Sending a live key to the sandbox returns `401`, which is deliberate: it turns the most common and most expensive configuration mistake into a loud failure on the first call rather than a silent one at settlement. parameters: LedgerVersion: name: Ledger-Version in: header required: false description: | Pins the request to a dated API release, for example `2026-09-01`. Omit to use the release pinned to the account. Pin explicitly in any integration you intend to maintain. schema: type: string format: date example: '2026-09-01' IdempotencyKeyRequired: name: Idempotency-Key in: header required: true description: | A client-generated unique key that makes this `POST` safe to retry. Use a UUIDv4 or another value with at least 128 bits of entropy, and generate it once per logical operation, not once per HTTP attempt. Semantics: - **First use.** The request is processed and the response is stored against the key for 24 hours. - **Replay with an identical body.** The stored response is returned verbatim, with the original status code and an `Idempotent-Replay: true` header. No new resource is created. - **Replay while the original is still in flight.** `409` with the `idempotency_in_progress` error code. Retry after a short delay. - **Reuse with a different body.** `409` with `idempotency_key_reused`. Nothing is processed. This is a bug in the caller: the same key is being used for two different operations. - **After 24 hours.** The key is forgotten and a request bearing it is treated as new. An integration that retries on a schedule longer than 24 hours must not rely on this header for deduplication. The key is scoped to the account and the endpoint. The same key may be used on `createInvoice` and on `createPayment` without collision. schema: type: string minLength: 16 maxLength: 255 example: 8f14e45f-ea1b-4c2d-9a35-7b1d6e0c3a92 IdempotencyKeyOptional: name: Idempotency-Key in: header required: false description: | Optional here, because this operation is already idempotent by state: repeating it against a resource that has already made the transition returns the current resource unchanged. Supply a key anyway if you want the stored-response guarantee described on `createInvoice`. schema: type: string minLength: 16 maxLength: 255 IfMatchRequired: name: If-Match in: header required: true description: | The `ETag` from your most recent read of the resource. Required. A request without it is rejected with `428`; a request with a stale value is rejected with `412`. schema: type: string example: '"a7f3c1e9b2d4"' InvoiceId: name: invoice_id in: path required: true description: The invoice identifier, for example `inv_7TgH2ReQvA`. schema: type: string maxLength: 64 pattern: '^inv_[A-Za-z0-9]{1,58}$' PaymentId: name: payment_id in: path required: true description: The payment identifier, for example `pay_3MvB9kLd7Q`. schema: type: string maxLength: 64 pattern: '^pay_[A-Za-z0-9]{1,58}$' RefundId: name: refund_id in: path required: true description: The refund identifier, for example `ref_5QpZ1nTc6E`. schema: type: string maxLength: 64 pattern: '^ref_[A-Za-z0-9]{1,58}$' Limit: name: limit in: query required: false description: Maximum number of records to return in one page. schema: type: integer format: int32 minimum: 1 maximum: 100 default: 20 Cursor: name: cursor in: query required: false description: | An opaque cursor from the `next_cursor` field of a previous response. Cursors are opaque by contract: do not decode, construct, or store them beyond the life of a pagination loop. Cursor pagination is used instead of numeric offsets so that records created during a long walk neither shift results nor cause a record to be skipped. schema: type: string maxLength: 512 headers: Location: description: The canonical URL of the newly created resource. required: true schema: type: string format: uri-reference example: /v1/invoices/inv_7TgH2ReQvA ETag: description: | An opaque validator for the current representation. Supply it as `If-Match` on a subsequent write. required: true schema: type: string example: '"a7f3c1e9b2d4"' IdempotentReplay: description: | Present and `true` only when the response was replayed from a stored idempotency record rather than produced by fresh processing. required: false schema: type: boolean example: true RateLimit: description: | Remaining quota for the active window, using the structured-field syntax of the IETF draft `draft-ietf-httpapi-ratelimit-headers` (revision 11). For example, `"default";r=94;t=41` means 94 requests remain in the policy named `default` and the window resets in 41 seconds. That document is an Internet-Draft, not yet an RFC, so the syntax may still change. `Retry-After` on a `429` is standardized (RFC 9110) and is the value to depend on; treat these two fields as advisory pacing information. required: false schema: type: string example: '"default";r=94;t=41' RateLimitPolicy: description: | The quota policy in force, using the structured-field syntax of `draft-ietf-httpapi-ratelimit-headers` (revision 11). For example, `"default";q=100;w=60` means 100 requests per 60-second window. required: false schema: type: string example: '"default";q=100;w=60' RetryAfter: description: | Seconds to wait before retrying. Honor this value; retrying sooner consumes quota without being served and extends the throttle. required: true schema: type: integer format: int32 minimum: 0 example: 41 schemas: Money: type: object description: | A monetary value. `amount` is an integer in the **minor unit** of `currency`, never a decimal. Floating-point types cannot represent every decimal fraction exactly, so accumulating them across a ledger drifts; integers do not. The minor unit depends on the currency, and there is no safe default: - Two-decimal currencies (`INR`, `USD`, `EUR`): `amount` is in paise or cents. `450000` INR is Rs 4,500.00. - Zero-decimal currencies (`JPY`, `KRW`, `VND`): `amount` is the whole unit. `4500` JPY is Yen 4,500. - Three-decimal currencies (`KWD`, `BHD`, `TND`): `amount` is thousandths. `4500` KWD is KD 4.500. Do not hardcode a divisor of 100 when formatting for display. required: - amount - currency properties: amount: type: integer format: int64 description: | The value in the currency's minor unit. Non-negative on all resources returned by this API; direction is conveyed by the resource type, not by the sign. minimum: 0 examples: - 4500000 currency: type: string description: A three-letter uppercase ISO 4217 code. pattern: '^[A-Z]{3}$' examples: - INR additionalProperties: false InvoiceStatus: type: string description: | The lifecycle state of an invoice. ``` draft ──finalize──> open ──payment in full──> paid │ │ │ ├──void──> void │ │ │ └──dunning exhausted──> uncollectible │ └──delete──> (gone; draft invoices are the only deletable ones) ``` `paid`, `void`, and `uncollectible` are terminal. Treat any value you do not recognize as terminal-unknown and route it to a human rather than failing the process. enum: - draft - open - paid - void - uncollectible InvoiceLineItem: type: object required: - description - quantity - unit_amount properties: description: type: string description: The text shown to the customer for this line. minLength: 1 maxLength: 500 quantity: type: integer format: int32 description: Number of units billed. Must be at least 1. minimum: 1 unit_amount: type: integer format: int64 description: | Price per unit in the invoice currency's minor unit. The line total is `quantity * unit_amount`, computed server-side in integer arithmetic. minimum: 0 additionalProperties: false Invoice: type: object required: - id - object - status - customer_id - currency - total - amount_paid - amount_remaining - line_items - created_at properties: id: type: string description: Opaque identifier, prefixed `inv_`. examples: - inv_7TgH2ReQvA object: type: string description: | Always `invoice`. Present so that a heterogeneous collection or a webhook payload can be dispatched without inspecting the URL it arrived from. const: invoice status: $ref: '#/components/schemas/InvoiceStatus' invoice_number: type: - string - 'null' description: | The gapless sequential number assigned at finalization. `null` while the invoice is a `draft`, and never `null` afterwards. examples: - INV-2026-004182 customer_id: type: string description: The customer billed, prefixed `cus_`. examples: - cus_9ZQx4Kp2Lm currency: type: string description: | ISO 4217 code for every monetary field on this invoice. An invoice is single-currency; bill in two currencies with two invoices. pattern: '^[A-Z]{3}$' total: $ref: '#/components/schemas/Money' amount_paid: $ref: '#/components/schemas/Money' amount_remaining: $ref: '#/components/schemas/Money' line_items: type: array description: | Frozen at finalization. Editable only while `status` is `draft`. minItems: 1 maxItems: 250 items: $ref: '#/components/schemas/InvoiceLineItem' due_at: type: - string - 'null' format: date-time description: | When payment falls due. `null` means due on receipt. Informational only; the API does not auto-transition an overdue invoice. created_at: type: string format: date-time description: When the invoice was created, in UTC. finalized_at: type: - string - 'null' format: date-time description: When the invoice moved to `open`. `null` while `draft`. voided_at: type: - string - 'null' format: date-time description: When the invoice was voided. `null` unless `status` is `void`. void_reason: type: - string - 'null' enum: - duplicate - fraudulent - issued_in_error - other - null description: | The reason supplied to `voidInvoice`. `null` unless `status` is `void`. Added in release 2026-06-15; before that, the reason was stored but never returned, so an audit could not read it back through the API. amount_due: $ref: '#/components/schemas/Money' deprecated: true description: | **Deprecated.** Identical in value to `amount_remaining`, which supersedes it. Two names for one number is an ambiguity this reference cannot resolve by explaining it, so one is going away. Populated in every release up to and including the first dated release after 2027-06-15, then removed. Deprecated in 2026-06-15. additionalProperties: false InvoiceCreateRequest: type: object required: - customer_id - currency - line_items properties: customer_id: type: string description: The customer to bill. pattern: '^cus_[A-Za-z0-9]{1,58}$' currency: type: string description: ISO 4217 code. Cannot be changed after creation. pattern: '^[A-Z]{3}$' due_at: type: - string - 'null' format: date-time description: | When payment falls due. Must be in the future at finalization time, not at creation time; a draft may sit past its own due date. line_items: type: array minItems: 1 maxItems: 250 items: $ref: '#/components/schemas/InvoiceLineItem' additionalProperties: false InvoiceUpdateRequest: type: object description: | A partial update. Only the fields present are changed. Sending `line_items` replaces the whole array; there is no per-line patch. minProperties: 1 properties: due_at: type: - string - 'null' format: date-time line_items: type: array minItems: 1 maxItems: 250 items: $ref: '#/components/schemas/InvoiceLineItem' additionalProperties: false InvoiceVoidRequest: type: object properties: reason: type: string description: | Why the invoice is being voided. Returned as `void_reason` on the invoice, including in the `invoice.voided` event. enum: - duplicate - fraudulent - issued_in_error - other additionalProperties: false InvoiceList: type: object required: - object - data - has_more properties: object: type: string const: list data: type: array items: $ref: '#/components/schemas/Invoice' has_more: type: boolean description: | Whether further pages exist. Terminate a pagination loop on `has_more` being `false`, not on an empty `data` array; a page may legitimately be empty while more results remain. next_cursor: type: - string - 'null' description: | Pass as `cursor` to fetch the next page. `null` when `has_more` is `false`. additionalProperties: false PaymentStatus: type: string description: | The outcome of a payment attempt. - `processing`: submitted to the network, outcome not yet known. Not terminal. Wait for a webhook. - `succeeded`: funds captured. Terminal. - `failed`: declined or rejected. Terminal. The invoice stays `open`. enum: - processing - succeeded - failed Payment: type: object required: - id - object - status - invoice_id - amount - amount_refunded - amount_refund_pending - created_at properties: id: type: string examples: - pay_3MvB9kLd7Q object: type: string const: payment status: $ref: '#/components/schemas/PaymentStatus' invoice_id: type: string pattern: '^inv_[A-Za-z0-9]{1,58}$' amount: $ref: '#/components/schemas/Money' amount_refunded: $ref: '#/components/schemas/Money' description: | The total of `succeeded` refunds against this payment: money that has actually been returned to the payer. From release 2026-09-01. Earlier releases also counted refunds still `processing` here. amount_refund_pending: $ref: '#/components/schemas/Money' description: | The total of refunds against this payment that are still `processing`. That amount is reserved: the remaining refund headroom is `amount - amount_refunded - amount_refund_pending`. Returned from release 2026-09-01; see the migration guide. failure_code: type: - string - 'null' description: | A stable, machine-readable decline reason. `null` unless `status` is `failed`. Branch on this, not on `failure_message`. The error reference lists every code with what to show the payer; one of them, `card_lost_or_stolen`, must be shown as a generic decline. examples: - insufficient_funds failure_message: type: - string - 'null' description: | A human-readable explanation, in English, intended for your support staff. Do not show it to the payer verbatim and do not parse it; the wording is not part of the contract. created_at: type: string format: date-time additionalProperties: false PaymentCreateRequest: type: object required: - invoice_id - amount - payment_method_id properties: invoice_id: type: string description: The `open` invoice to pay. pattern: '^inv_[A-Za-z0-9]{1,58}$' amount: $ref: '#/components/schemas/Money' payment_method_id: type: string description: A stored payment method belonging to the invoice customer. pattern: '^pm_[A-Za-z0-9]{1,58}$' additionalProperties: false RefundStatus: type: string description: | The outcome of a refund. `processing` is not terminal; `succeeded` and `failed` are. While a refund is `processing`, its amount is reserved against the payment. A refund that ends `failed` returns nothing to the payer and releases that reservation. enum: - processing - succeeded - failed Refund: type: object required: - id - object - status - payment_id - amount - created_at properties: id: type: string examples: - ref_5QpZ1nTc6E object: type: string const: refund status: $ref: '#/components/schemas/RefundStatus' payment_id: type: string pattern: '^pay_[A-Za-z0-9]{1,58}$' amount: $ref: '#/components/schemas/Money' reason: type: - string - 'null' enum: - duplicate - fraudulent - requested_by_customer - null created_at: type: string format: date-time settled_at: type: - string - 'null' format: date-time description: | When the refund reached a terminal state, distinct from `created_at`. `null` while `processing`. Reconciliation should align refunds to this value rather than to request time. Added in release 2026-09-01. additionalProperties: false RefundCreateRequest: type: object properties: amount: $ref: '#/components/schemas/Money' reason: type: string enum: - duplicate - fraudulent - requested_by_customer additionalProperties: false WebhookEvent: type: object description: | The envelope for every webhook delivery. **Verify before you parse.** Each delivery carries a `Ledger-Signature` header of the form `t=1790000000,v1=5257a869e7...`, where `v1` is the lowercase hex HMAC-SHA256 of the exact bytes `.`, keyed with your endpoint's signing secret. Four rules, each of which prevents a real attack or a real outage: 1. Compute the HMAC over the **raw** body, before any JSON parsing. Re-serializing changes whitespace and key order, which changes the digest. 2. Compare digests with a constant-time function. A byte-by-byte `==` leaks the correct signature through timing. 3. Reject a delivery whose `t` is more than five minutes from your clock. Without this check a valid signature can be replayed forever. 4. Treat delivery as **at-least-once**. Retries and network partitions produce duplicates, so deduplicate on `id` and make your handler idempotent. Events may also arrive out of order; use `created_at` and the resource's own state, not arrival order, to decide what is current. required: - id - object - type - created_at - data properties: id: type: string description: Unique per event. Deduplicate on this. examples: - evt_2WnF6yBk8R object: type: string const: event type: type: string description: The event name, for example `invoice.paid`. examples: - invoice.paid created_at: type: string format: date-time data: type: object description: The resource snapshot at the moment the event fired. additionalProperties: false InvoiceEvent: allOf: - $ref: '#/components/schemas/WebhookEvent' - type: object properties: data: $ref: '#/components/schemas/Invoice' PaymentEvent: allOf: - $ref: '#/components/schemas/WebhookEvent' - type: object properties: data: $ref: '#/components/schemas/Payment' RefundEvent: allOf: - $ref: '#/components/schemas/WebhookEvent' - type: object properties: data: $ref: '#/components/schemas/Refund' Problem: type: object description: | An error, in the `application/problem+json` format defined by RFC 9457. Branch on `code`, which is stable, never on `title` or `detail`, whose wording may change without notice. required: - type - title - status - code properties: type: type: string format: uri description: | A URI identifying the problem type. Dereferencing it returns the documentation page for this error. examples: - https://docs.ledgerapi.dev/errors/invoice_not_editable title: type: string description: A short, human-readable summary of the problem type. examples: - Invoice is not editable status: type: integer format: int32 description: | The HTTP status code, repeated here so the body remains self-describing when it is logged away from its response. minimum: 400 maximum: 599 examples: - 422 code: type: string description: | The stable, machine-readable error code. This is the contract. examples: - invoice_not_editable detail: type: string description: | An explanation specific to this occurrence. Safe to log, not safe to parse. examples: - Invoice inv_7TgH2ReQvA is in state 'open' and can no longer be edited. instance: type: string format: uri-reference description: A URI reference identifying this specific occurrence. request_id: type: string description: | Quote this when contacting support. It identifies the exact request in our logs. examples: - req_8Kd2PqL4vN errors: type: array description: | Present on validation failures. Reports **every** invalid field, not just the first, so a form can be corrected in one pass. items: $ref: '#/components/schemas/FieldError' additionalProperties: true FieldError: type: object required: - field - code properties: field: type: string description: | A JSON Pointer (RFC 6901) to the offending member of the request body, for example `/line_items/2/unit_amount`. examples: - /line_items/2/unit_amount code: type: string description: A stable code for this field-level failure. examples: - below_minimum detail: type: string description: A human-readable explanation of this field failure. responses: Unauthorized: description: | The credential is missing, malformed, revoked, or belongs to the other environment. Do not retry without changing the credential. headers: WWW-Authenticate: description: The challenge, per RFC 9110. required: true schema: type: string example: Bearer realm="ledger-api" content: application/problem+json: schema: $ref: '#/components/schemas/Problem' example: type: https://docs.ledgerapi.dev/errors/invalid_api_key title: Invalid API key status: 401 code: invalid_api_key detail: The provided key is not valid for this environment. request_id: req_8Kd2PqL4vN Forbidden: description: | The credential is valid but lacks the privilege for this operation. Retrying is pointless; the key needs different scopes. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' example: type: https://docs.ledgerapi.dev/errors/insufficient_scope title: Insufficient scope status: 403 code: insufficient_scope detail: This key is restricted to read operations. request_id: req_8Kd2PqL4vN NotFound: description: | No such resource is visible to this credential. This status is also returned when the resource exists but belongs to another account. Returning `403` there would confirm the identifier is real, which lets an attacker enumerate the identifier space. The ambiguity is deliberate. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' example: type: https://docs.ledgerapi.dev/errors/resource_not_found title: Resource not found status: 404 code: resource_not_found detail: No invoice with ID inv_7TgH2ReQvA is visible to this account. request_id: req_8Kd2PqL4vN IdempotencyConflict: description: | Either the original request bearing this `Idempotency-Key` is still in flight (`idempotency_in_progress`, safe to retry after a short delay), or the key has already been used with a different body (`idempotency_key_reused`, a caller bug that retrying will not fix). content: application/problem+json: schema: $ref: '#/components/schemas/Problem' example: type: https://docs.ledgerapi.dev/errors/idempotency_key_reused title: Idempotency key reused status: 409 code: idempotency_key_reused detail: This key was first used with a different request body. request_id: req_8Kd2PqL4vN PreconditionFailed: description: | The `If-Match` value does not match the resource's current `ETag`. The resource changed since you read it and nothing was written. Re-read, re-apply your change, and retry. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' example: type: https://docs.ledgerapi.dev/errors/precondition_failed title: Precondition failed status: 412 code: precondition_failed detail: The invoice has been modified since the supplied ETag was issued. request_id: req_8Kd2PqL4vN UnprocessableContent: description: | The request was syntactically valid JSON but semantically wrong: a field failed validation, or the operation is not legal from the resource's current state. Inspect `code`, and `errors` when present. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' example: type: https://docs.ledgerapi.dev/errors/validation_failed title: Validation failed status: 422 code: validation_failed detail: One or more fields are invalid. 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: /currency code: invalid_format detail: currency must be three uppercase letters, for example INR. PreconditionRequired: description: | The operation requires `If-Match` and none was supplied. Read the resource, take its `ETag`, and retry. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' example: type: https://docs.ledgerapi.dev/errors/precondition_required title: Precondition required status: 428 code: precondition_required detail: This operation requires an If-Match header. request_id: req_8Kd2PqL4vN TooManyRequests: description: | Quota for the current window is exhausted. Wait for `Retry-After` seconds, then retry with exponential backoff and jitter. Retrying without backoff, or in lockstep across many workers, will keep you throttled. headers: Retry-After: $ref: '#/components/headers/RetryAfter' RateLimit: $ref: '#/components/headers/RateLimit' RateLimit-Policy: $ref: '#/components/headers/RateLimitPolicy' content: application/problem+json: schema: $ref: '#/components/schemas/Problem' example: type: https://docs.ledgerapi.dev/errors/rate_limit_exceeded title: Rate limit exceeded status: 429 code: rate_limit_exceeded detail: 100 requests per 60 seconds exceeded for this account. request_id: req_8Kd2PqL4vN InternalServerError: description: | Something failed on our side. A `5xx` on a write operation means the outcome is **unknown**, not failed. Retry with the same `Idempotency-Key`: if the original request did commit, you get the stored response back instead of a duplicate. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' example: type: https://docs.ledgerapi.dev/errors/internal_error title: Internal server error status: 500 code: internal_error detail: An unexpected error occurred. Quote request_id when contacting support. request_id: req_8Kd2PqL4vN