> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kordio.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Refund a transaction (partial or full)

> Books an inverse transaction for the requested amount,
scaling every leg of the original (including legs in other
currencies) by `amount / gross_in(currency)`. Banker's
rounding; residual on the largest leg per currency, so the
new transaction is balanced per currency.

`currency` is the base currency the caller is refunding. For
single-currency originals it may be omitted and is inferred.
For multi-currency originals it is **required**; omitting it
returns `refund_currency_required` (422).

Sum of prior refunds is tracked per currency: a 100% USDC
refund does not block a later 100% BTC refund on a USDC+BTC
original. Refunds that would exceed the original's remaining
amount in the requested currency return
`partial_refund_exceeds_original` (422). Idempotent on
`Idempotency-Key`.




## OpenAPI

````yaml /ledger/api-reference/openapi.yaml post /ledger/v1/transactions/{id}/refund
openapi: 3.1.0
info:
  title: Kordio Ledger API
  version: v1
  summary: Double-entry ledger as a service.
  description: >
    A double-entry ledger you can call over HTTP. Every write balances

    per currency, is idempotent by client key, and is append-only.

    Corrections are reversal transactions, not edits.


    This is one of two APIs on `api.kordio.io`. The other is the

    [spend control
    API](https://docs.kordio.io/agents/api-reference/introduction),

    which governs what AI agents may spend. They share a host and nothing else:

    different credentials, different envelopes, different pagination.


    New here? Start with the

    [ledger quickstart](https://docs.kordio.io/ledger/quickstart).


    ### Response envelope


    Every successful response is shaped as:


    ```json

    { "data": { "object": "<type>", ... }, "livemode": false, "request_id":
    "req_..." }

    ```


    Lists are flat (Stripe convention):


    ```json

    { "object": "list", "data": [...], "has_more": false, "next_cursor": null,
      "livemode": false, "request_id": "req_..." }
    ```


    Errors:


    ```json

    { "error": { "code": "...", "message": "...", "hint": "...", "docs_url":
    "...",
                 "request_id": "req_..." },
      "livemode": false, "request_id": "req_..." }
    ```


    `request_id` is also returned on the `X-Request-Id` header.


    ### Modes


    Each OAuth client is registered as `test` or `live`. The mode is

    encoded into the token and surfaced as the top-level `livemode`

    boolean on every response. Test and live datasets are operationally

    distinct: no `?test=true` flag, no leakage.


    ### Idempotency


    Required on writes. Same `(ledger_id, Idempotency-Key)` always

    returns the original transaction, forever. Header or body, your

    choice. Header wins if both are present.


    ### Rate limits


    Limits are enforced per OAuth client, separately for read and write

    requests. Every response carries `X-RateLimit-Limit`,

    `X-RateLimit-Remaining`, and `X-RateLimit-Reset` (seconds until the

    window resets). When a client exceeds its limit the request is

    rejected with `429` and a `Retry-After` header giving the number of

    seconds to wait before retrying.


    ### Ledger selection


    Every ledger-scoped endpoint (everything under `/v1/` except

    `/v1/ledgers`, `/v1/organizations`, and the OAuth endpoints) needs

    to know which ledger to operate on. The ledger is resolved from,

    in order: the access token's `ledger_id` claim, the `X-Ledger-Id`

    request header, or a `ledger_id` path parameter. List your ledgers

    via `GET /v1/ledgers`.
  contact:
    name: Kordio engineering
    email: support@kordio.io
    url: https://docs.kordio.io
  license:
    name: Proprietary
    url: https://kordio.io/terms-and-conditions
servers:
  - url: https://api.kordio.io
    description: Production
  - url: http://localhost:4000
    description: Local development
security:
  - oauth2_read:
      - ledger:read
  - oauth2_write:
      - ledger:write
tags:
  - name: Authentication
    description: |
      OAuth 2.0 client_credentials. Every machine call needs a Bearer
      token; humans get one through the dashboard, services mint their
      own.
  - name: Accounts
    description: |
      Named buckets of value. Caller-chosen IDs (`cash:usd`,
      `accounts_receivable:acme`, `revenue:subscriptions`), caller-chosen
      currencies (`USD`, `USDC`, `BTC`, `POINTS`, anything goes), one of
      five accounting types.
  - name: Transactions
    description: |
      Balanced sets of postings. The unit of mutation. Reverse with
      `POST /v1/transactions/:id/reverse`; mutate `metadata` only with
      `PATCH`.
  - name: Postings
    description: |
      The atomic ledger entry. Always part of a transaction, never
      created or modified standalone.
  - name: Events
    description: |
      Same payloads webhooks deliver. The events log is the audit trail
      of every state-changing operation.
  - name: Webhooks
    description: |
      Endpoints that receive signed event payloads. Signatures are
      `HMAC-SHA256(secret, "<timestamp>.<raw_body>")` carried in the
      `Kordio-Signature` header.

      A delivery is considered successful on any 2xx response. Failures
      (non-2xx, timeout, or network error) are retried with exponential
      backoff at 30s, 5m, 1h, 6h, and 24h, for a maximum of 5 attempts;
      after the 5th failure the delivery is marked dead and not retried.
  - name: Reports
    description: |
      Read-only financial reports. Trial balance for tenant health,
      per-account statement for a period, point-in-time balance via
      `?at=<ts>` on the balance endpoint.
  - name: Reserves
    description: |
      Sweep / release / claw funds held against a counterparty.
      Reserves are accounts with `account_kind = "restricted"` and a
      `counterparty_ref`; listing and balance reads use the standard
      `/v1/accounts` surface. New event types `reserve.swept`,
      `reserve.released`, `reserve.clawed`.
  - name: Reconciliation
    description: |
      Match external system snapshots (custody balances, settlement
      confirmations, blockchain receipts) against unreconciled
      postings. Mark matched postings as reconciled with an external
      reference.
  - name: Sources
    description: |
      Named external systems and their ingested external transactions.
      Push statement rows once, idempotently, then run reconciliation
      per source. `GET /v1/external_transactions?status=open` is the
      break list.
  - name: Periods
    description: |
      Close and reopen accounting periods. A closed period rejects new
      postings whose `value_date` falls inside it, enforced both at
      the application layer and at the database via trigger.
  - name: Ledgers
    description: |
      A ledger is a single namespace of accounts, transactions, and
      postings, bound to one mode (test/live). Most resources are
      scoped to a ledger.
  - name: Organizations
    description: |
      Top-level tenant container. Owns ledgers, members, invitations.
      Org-scoped endpoints don't require `X-Ledger-Id`.
  - name: System
    description: Health, metadata, discovery.
paths:
  /ledger/v1/transactions/{id}/refund:
    post:
      tags:
        - Transactions
      summary: Refund a transaction (partial or full)
      description: |
        Books an inverse transaction for the requested amount,
        scaling every leg of the original (including legs in other
        currencies) by `amount / gross_in(currency)`. Banker's
        rounding; residual on the largest leg per currency, so the
        new transaction is balanced per currency.

        `currency` is the base currency the caller is refunding. For
        single-currency originals it may be omitted and is inferred.
        For multi-currency originals it is **required**; omitting it
        returns `refund_currency_required` (422).

        Sum of prior refunds is tracked per currency: a 100% USDC
        refund does not block a later 100% BTC refund on a USDC+BTC
        original. Refunds that would exceed the original's remaining
        amount in the requested currency return
        `partial_refund_exceeds_original` (422). Idempotent on
        `Idempotency-Key`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/Expand'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - amount
              properties:
                amount:
                  oneOf:
                    - type: string
                      pattern: ^\d+$
                    - type: integer
                      minimum: 1
                  description: >-
                    Refund amount in minor units. Accepts a positive decimal
                    string (recommended) or an integer on write.
                  example: '5000000'
                currency:
                  type: string
                  description: |
                    The base currency to refund. Optional for
                    single-currency originals (inferred). Required
                    for multi-currency originals; omitting returns
                    `refund_currency_required` (422).
                  example: USDC
                idempotency_key:
                  type: string
                  description: >-
                    Optional body alternative to the `Idempotency-Key` header.
                    Header wins.
                metadata:
                  type: object
                  additionalProperties: true
            examples:
              partial:
                value:
                  amount: '5000000'
                  idempotency_key: refund_pi_acme_1234_partial_01
                  metadata:
                    reason: customer requested partial refund
      responses:
        '200':
          description: Idempotency replay (original refund returned)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Transaction'
        '201':
          description: Refund transaction created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Transaction'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Invalid refund amount, or refund would exceed original
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                exceeds:
                  value:
                    error:
                      code: partial_refund_exceeds_original
                      message: refund amount exceeds the remaining refundable amount
                      hint: >-
                        fetch the original via GET /v1/transactions/:id to see
                        the remaining refundable balance
                      docs_url: >-
                        https://docs.kordio.io/errors#partial_refund_exceeds_original
                currency_required:
                  value:
                    error:
                      code: refund_currency_required
                      message: >-
                        currency is required when the original has postings in
                        multiple currencies
                      hint: 'pass `currency` matching one of: USDC, BTC'
                      docs_url: https://docs.kordio.io/errors#refund_currency_required
      security:
        - oauth2_write:
            - ledger:write
components:
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema:
        type: string
        minLength: 1
        maxLength: 255
        pattern: ^[A-Za-z0-9_:.\-]+$
        example: pi_acme_1234_capture
      description: |
        Required on writes. Stable identifier you choose. The same key
        always returns the same transaction, forever. Can also be supplied as
        `idempotency_key` in the request body. Header wins.

        Allowed character set: `A-Z`, `a-z`, `0-9`, `_`, `:`, `.`, `-`.
        Max 255 bytes. Replays of an accepted key return the original
        response with header `Idempotent-Replayed: true` so callers can
        tell a replay from a freshly-committed result.
    Expand:
      name: expand
      in: query
      required: false
      style: form
      explode: false
      schema:
        type: array
        items:
          type: string
          enum:
            - postings.account
            - balances
      description: |
        Comma-separated. `postings.account` inlines each posting's
        account object; `balances` adds the post-commit balance of every
        touched account.
  schemas:
    Transaction:
      allOf:
        - $ref: '#/components/schemas/EnvelopeFields'
        - type: object
          required:
            - object
            - id
            - postings
          properties:
            object:
              type: string
              enum:
                - transaction
            id:
              type: string
              format: uuid
              example: 3061ec4e-c959-49ba-a0f6-99186a7bd5d8
            idempotency_key:
              type: string
              example: pi_acme_1234_capture
            reverses:
              type: string
              format: uuid
              nullable: true
              description: Set when this transaction reverses another.
            reversed_by:
              type: string
              format: uuid
              nullable: true
              description: Set when this transaction has been reversed.
            refunds:
              type: string
              format: uuid
              nullable: true
              description: Set when this transaction refunds another (partial or full).
            refund_amount:
              type: string
              pattern: ^-?\d+$
              nullable: true
              description: >-
                Minor units refunded, as a decimal string. Parse with a
                big-integer or decimal type, never a JS number. Present only on
                refund transactions.
            exchange_rate:
              type: string
              nullable: true
              description: |
                Decimal string. Present only when the transaction has
                postings in more than one currency. The rate that was
                in effect at write time. It is persisted for audit and
                restatement, never recomputed.
              example: '1.0823'
            base_currency:
              type: string
              nullable: true
              description: >-
                Currency that the exchange rate quotes from. Set with
                `exchange_rate`.
              example: USD
            quote_currency:
              type: string
              nullable: true
              description: >-
                Currency that the exchange rate quotes to. Set with
                `exchange_rate`.
              example: USDC
            metadata:
              type: object
              additionalProperties: true
              example:
                payment_intent: pi_acme_1234
                channel: web
            postings:
              type: array
              items:
                $ref: '#/components/schemas/Posting'
            balances:
              type: array
              items:
                $ref: '#/components/schemas/Balance'
              description: Returned only when `?expand=balances`.
            value_date:
              type: string
              format: date-time
            booking_date:
              type: string
              format: date-time
            created_at:
              type: string
              format: date-time
            updated_at:
              type: string
              format: date-time
    ErrorResponse:
      type: object
      required:
        - error
        - livemode
        - request_id
      properties:
        livemode:
          type: boolean
          example: false
        request_id:
          type: string
          example: req_3LhM8XKx9q4hQv
        error:
          type: object
          required:
            - code
            - message
          properties:
            code:
              type: string
              description: Machine-readable. Stable across versions.
              enum:
                - invalid_request
                - missing_idempotency_key
                - unauthorized
                - forbidden
                - insufficient_scope
                - invalid_client
                - invalid_grant
                - invalid_scope
                - not_found
                - unbalanced
                - currency_mismatch
                - unknown_account
                - already_exists
                - already_reversed
                - immutable_field
                - insufficient_funds
                - period_closed
                - precondition_failed
                - duplicate_external_ref
                - partial_refund_exceeds_original
                - refund_currency_required
                - rotation_in_progress
                - webhook_endpoint_disabled
                - reserve_insufficient_funds
                - reserve_currency_mismatch
                - reserve_release_exceeds_balance
                - account_template_unknown
                - invalid_state
                - unsupported_grant_type
                - rate_limited
                - internal_error
                - tenant_suspended
                - fresh_authentication_required
            message:
              type: string
              description: Human-readable one-liner.
            hint:
              type: string
              description: Actionable next step.
            param:
              type: string
              nullable: true
              description: Which request field caused the failure.
            details:
              type: object
              additionalProperties: true
              description: >-
                Code-specific structured detail (e.g. `by_currency` for
                unbalanced).
            docs_url:
              type: string
              format: uri
            request_id:
              type: string
              description: Echoes the top-level `request_id`. Stripe-compatible placement.
    EnvelopeFields:
      type: object
      properties:
        object:
          type: string
          description: >-
            Discriminator naming the shape of this resource (e.g. `account`,
            `transaction`).
          example: account
    Posting:
      type: object
      required:
        - object
        - account
        - amount
        - currency
        - direction
      properties:
        object:
          type: string
          enum:
            - posting
        id:
          type: integer
          format: int64
          example: 14829
        transaction:
          oneOf:
            - type: string
              format: uuid
            - $ref: '#/components/schemas/Transaction'
        account:
          oneOf:
            - type: string
              example: accounts_receivable:acme
            - $ref: '#/components/schemas/AccountCompact'
          description: >-
            A string id by default; an Account object when
            `?expand=postings.account`.
        amount:
          type: string
          pattern: ^-?\d+$
          description: >-
            Amount in minor units, as a decimal string. Parse with a big-integer
            or decimal type, never a JS number. 1.00 USDC = "1000000" at 6
            decimals.
          example: '9700000'
        currency:
          type: string
          example: USDC
        direction:
          type: string
          enum:
            - debit
            - credit
          example: credit
        pending:
          type: boolean
          default: false
          description: Pending postings don't move the `posted` balance until committed.
        tags:
          type: object
          additionalProperties:
            type: string
          example:
            region: EU
            department: sales
        value_date:
          type: string
          format: date-time
        created_at:
          type: string
          format: date-time
    Balance:
      allOf:
        - $ref: '#/components/schemas/EnvelopeFields'
        - type: object
          required:
            - object
            - account
            - currency
            - posted
            - pending
            - available
          properties:
            object:
              type: string
              enum:
                - balance
            account:
              type: string
              example: accounts_receivable:acme
            currency:
              type: string
              example: USDC
            posted:
              type: string
              pattern: ^-?\d+$
              description: >-
                Signed minor units from confirmed postings, as a decimal string.
                Parse with a big-integer or decimal type, never a JS number.
              example: '9700000'
            pending:
              type: string
              pattern: ^-?\d+$
              description: >-
                Signed minor units from `pending: true` postings, as a decimal
                string. Parse with a big-integer or decimal type, never a JS
                number.
              example: '0'
            available:
              type: string
              pattern: ^-?\d+$
              description: >-
                posted + pending, as a decimal string. Parse with a big-integer
                or decimal type, never a JS number.
              example: '9700000'
    AccountCompact:
      type: object
      properties:
        object:
          type: string
          enum:
            - account
        id:
          type: string
        type:
          type: string
        currency:
          type: string
  responses:
    NotFound:
      description: Not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            default:
              value:
                error:
                  code: not_found
                  message: account not found
                  param: id
                  docs_url: https://docs.kordio.io/errors#not_found
  securitySchemes:
    oauth2_read:
      type: oauth2
      description: |
        OAuth 2.0 client_credentials grant. Send `client_id:client_secret`
        as HTTP Basic, or as form fields. Tokens are HS256 JWTs valid for
        one hour (configurable). Decode them at jwt.io.
      flows:
        clientCredentials:
          tokenUrl: /oauth/token
          scopes:
            ledger:read: Read accounts, balances, transactions, postings, events
            ledger:clients:read: List and inspect OAuth clients (no secrets)
    oauth2_write:
      type: oauth2
      description: Same flow; `ledger:write` is required for any mutation.
      flows:
        clientCredentials:
          tokenUrl: /oauth/token
          scopes:
            ledger:write: >-
              Create / patch / reverse / commit transactions and accounts;
              manage webhook endpoints
            ledger:clients:write: Create, rotate and delete OAuth clients

````