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

# Your own float, per currency

> **How much YOU can actually pay out, per currency.** This is the number a payout is checked against, so it is the one to show on a dashboard and the one to check before a batch run.

**This is not `GET /v1/balances`, and the difference matters.** `/v1/balances` reports the PROVIDER's wallet — a single pool that every tenant on this gateway shares. Most of what it shows is not yours. This endpoint reports your own claim on that pool. A tenant holding 2,000 UGX of float can read a 4,000,000 UGX pooled balance from `/v1/balances` and still have a payout refused with `insufficient_tenant_float`. If that has ever looked inexplicable, this endpoint is the explanation.

**`available_minor` is what a payout draws from.** It grows when a collection settles and shrinks when a payout is claimed. A payout larger than it is refused before the provider is ever contacted — your own float is checked first, so another tenant draining the shared wallet can never spend money you collected.

**`reserved_minor` is in flight, not lost.** A payout moves money from `available` to `reserved` at claim time and out of `reserved` when it settles; if it fails terminally the money returns to `available`. Money sitting in `reserved` is still in the wallet — it is spoken for, not spent.

**`provider_fees_minor` is a lifetime total of fees in BOTH directions, and it is already reflected in `available_minor`. Never subtract it yourself.** The two kinds of fee behave differently, which is why the total alone cannot tell you what to do with it:

- A **collection** fee was never yours to lose. The upstream provider appends it to
  what the customer pays and keeps it, so your wallet is credited the NET
  amount and there is nothing further to subtract.

- A **payout** fee IS yours: the provider debits it from the wallet on
  top of the amount delivered (a UGX 10,000 bank payout costs UGX 15,400
  at current bank rates). It has already been deducted from
  `available_minor` when the payout settled.


So `provider_fees_minor` is a reporting figure for reconciling against a provider statement. Subtracting it from `available_minor` would double-count the payout half and wrongly charge you for the collection half.

**Read-only, and not a reservation.** These figures are maintained for reporting; the authoritative check happens inside the payout transaction. Between reading this and calling `POST /v1/payouts`, your own concurrent payouts can move it. Treat a shortfall here as a reason not to try, never a success here as a guarantee.

**Available to every tenant.** Your own float position needs no capability grant — it is your data, and it touches no provider.

An empty array means you hold no float in any currency yet, which is the normal state before your first collection settles.




## OpenAPI

````yaml /openapi.json get /v1/float
openapi: 3.0.3
info:
  title: AptaPay Gateway
  version: 1.0.0
  description: >
    One provider-neutral payment API owned by many apps: collections, payouts,

    bank resolution and FX, with signed webhooks back.


    ## Authentication


    Every `/v1/*` request carries four headers. **A plain API key is not

    enough** — a key in a proxy log or an error report would be immediately

    usable, whereas a signature binds the request to a body and a moment.


    | Header | Value |

    | --- | --- |

    | `X-AptaPay-Key` | `{app_id}.{key_id}.{api_key}` |

    | `X-AptaPay-Timestamp` | unix seconds |

    | `X-AptaPay-Nonce` | 16-64 chars, `[A-Za-z0-9_-]`, unique per request |

    | `X-AptaPay-Signature` | `v1={hex HMAC-SHA256}` |


    The signature is HMAC-SHA256, with your **inbound** signing secret, over

    exactly these ten fields joined by `\n`:


    ```

    v1

    {APP_ID}

    {KEY_ID}

    {HOST}                      lowercased

    {METHOD}                    uppercased

    {PATH}                      no query, NOT url-decoded

    {CANONICAL_QUERY}           sorted, RFC3986-encoded, "" when absent

    {TIMESTAMP}

    {NONCE}

    {SHA256_HEX(RAW_BODY)}      sha256("") for GET/DELETE

    ```


    Every field earns its place. `CANONICAL_QUERY` is in there because without

    it `?limit=1` and `?limit=100000` sign identically, so a captured read

    signature could be replayed to dump everything. `HOST` stops a signature

    captured against one deployment replaying into another.


    The timestamp window is **asymmetric**: `-120s <= now - ts <= +30s`. A

    symmetric window is a ten-minute replay window, and there is no legitimate

    reason to sign in the future beyond small clock skew.


    A working reference implementation is in `docs/TENANT_INTEGRATION.md`, and

    `scripts/sign_request.mjs` signs a request from the terminal.


    ## Idempotency


    `Idempotency-Key` is **required** on every money-moving POST. A missing

    key is a 400 — the server never invents one, because a server-generated

    key is unique per request and protects nothing, which is worse than none

    because it looks like protection.


    - Replaying a completed key returns the **original response verbatim**,
      with its original status code. No second provider call.
    - The same key with **different parameters** is a 422, never a replay.

    - A concurrent duplicate is a 409 `request_in_progress`.


    ## Errors


    Branch on `error.code` and the three booleans, never on the HTTP status:

    `terminal` (reverse and tell the user), `retriable` (retry with the same

    key), `ambiguous` (**do not reverse** — poll instead).


    ## Amounts


    Integer minor units, with a per-currency exponent. **UGX, RWF, XAF and

    XOF are zero-decimal**: 2000 UGX is `2000`. Getting this wrong is a 100x

    error.


    ## Test mode is NOT a sandbox


    **Every request reaches a real provider. There is no sandbox, and no

    test host.** AptaPay routes to a single upstream provider, and that provider
    publishes

    exactly one API host for live and test traffic alike. No sandbox base URL

    exists anywhere in its reference documentation.


    So "test mode" here does not mean what it means on Stripe. It is a

    **second, real provider account** holding a small real balance, reached

    over the same wire as production. A test-mode payout moves real money out

    of a real wallet to a real phone number. It cannot be reversed, because

    the provider exposes no refund endpoint.


    | | What you might expect | What actually happens |

    | --- | --- | --- |

    | Host | a separate sandbox host | the one production host, always |

    | Money | simulated | real, in a real wallet |

    | Recipients | fake numbers accepted | real MSISDNs, really credited |

    | Undo | reverse the test charge | none — no refund endpoint exists |


    Two guardrails stand in for the sandbox, and they are permanent, not

    temporary scaffolding:


    - **A hard amount ceiling on every test-mode transaction**, enforced by
      this gateway *before* any provider call. Over it, you get a `422` and
      no request ever leaves.
    - **`is_test_mode` stamped on every transaction record**, so a test
      transaction is identifiable forever after the fact.

    Mode is a property of **which credentials you hold**, never of a request

    field — there is no `test: true` to send, and no header that switches it.

    A live key transacts live; that is the only thing that decides it. See

    `GET /healthz` for which mode a deployment is running.


    ## CORS


    There isn't any, deliberately. This is a server-to-server API and

    browsers must never call it: signing from client-side JS would put your

    secret in view-source. Cross-origin browser requests are denied.
servers:
  - url: https://api.pay.aptahq.com
    description: >-
      Production. There is no sandbox host, because the upstream provider has
      none — see "Test mode is NOT a sandbox". Sign the host you dial: the host
      is part of the canonical string, so a request dialled here must claim
      `api.pay.aptahq.com`.
security:
  - AptaPaySignature: []
tags:
  - name: Collections
    description: Money in.
  - name: Payouts
    description: Money out.
  - name: Refunds
    description: Reversals — 501 where the routed provider has no refund endpoint.
  - name: Transactions
    description: The gateway's own ledger of what it was asked to do.
  - name: Beneficiaries
    description: Saved recipients. Stored masked, names re-resolved at payout time.
  - name: Reference
    description: Corridors, banks, countries, balances, capability matrix.
  - name: Float
    description: Your own claim on the shared wallet pool — what you can actually pay out.
  - name: FX
    description: Exchange between your own wallets. Quote is free; exchange moves money.
  - name: Webhooks
    description: Provider ingress. Documented for completeness; tenants never call it.
  - name: Ops
    description: Health.
paths:
  /v1/float:
    get:
      tags:
        - Float
      summary: Your own float, per currency
      description: >
        **How much YOU can actually pay out, per currency.** This is the number
        a payout is checked against, so it is the one to show on a dashboard and
        the one to check before a batch run.


        **This is not `GET /v1/balances`, and the difference matters.**
        `/v1/balances` reports the PROVIDER's wallet — a single pool that every
        tenant on this gateway shares. Most of what it shows is not yours. This
        endpoint reports your own claim on that pool. A tenant holding 2,000 UGX
        of float can read a 4,000,000 UGX pooled balance from `/v1/balances` and
        still have a payout refused with `insufficient_tenant_float`. If that
        has ever looked inexplicable, this endpoint is the explanation.


        **`available_minor` is what a payout draws from.** It grows when a
        collection settles and shrinks when a payout is claimed. A payout larger
        than it is refused before the provider is ever contacted — your own
        float is checked first, so another tenant draining the shared wallet can
        never spend money you collected.


        **`reserved_minor` is in flight, not lost.** A payout moves money from
        `available` to `reserved` at claim time and out of `reserved` when it
        settles; if it fails terminally the money returns to `available`. Money
        sitting in `reserved` is still in the wallet — it is spoken for, not
        spent.


        **`provider_fees_minor` is a lifetime total of fees in BOTH directions,
        and it is already reflected in `available_minor`. Never subtract it
        yourself.** The two kinds of fee behave differently, which is why the
        total alone cannot tell you what to do with it:


        - A **collection** fee was never yours to lose. The upstream provider
        appends it to
          what the customer pays and keeps it, so your wallet is credited the NET
          amount and there is nothing further to subtract.

        - A **payout** fee IS yours: the provider debits it from the wallet on
          top of the amount delivered (a UGX 10,000 bank payout costs UGX 15,400
          at current bank rates). It has already been deducted from
          `available_minor` when the payout settled.


        So `provider_fees_minor` is a reporting figure for reconciling against a
        provider statement. Subtracting it from `available_minor` would
        double-count the payout half and wrongly charge you for the collection
        half.


        **Read-only, and not a reservation.** These figures are maintained for
        reporting; the authoritative check happens inside the payout
        transaction. Between reading this and calling `POST /v1/payouts`, your
        own concurrent payouts can move it. Treat a shortfall here as a reason
        not to try, never a success here as a guarantee.


        **Available to every tenant.** Your own float position needs no
        capability grant — it is your data, and it touches no provider.


        An empty array means you hold no float in any currency yet, which is the
        normal state before your first collection settles.
      operationId: listTenantFloat
      responses:
        '200':
          description: One entry per currency this tenant holds a position in.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIResult'
              example:
                code: 200
                message: OK
                data:
                  - currency: UGX
                    available_minor: 1520000
                    reserved_minor: 30000
                    collected_minor: 2450000
                    paid_out_minor: 900000
                    provider_fees_minor: 56300
                  - currency: KES
                    available_minor: 42000
                    reserved_minor: 0
                    collected_minor: 42000
                    paid_out_minor: 0
                    provider_fees_minor: 966
        '401':
          $ref: '#/components/responses/Unauthorized'
components:
  schemas:
    APIResult:
      type: object
      description: >
        The single response envelope, used by EVERY endpoint so a consuming app
        parses one shape everywhere.
      required:
        - code
        - message
      properties:
        code:
          type: integer
          example: 200
        message:
          type: string
          example: OK
        data:
          description: Present on success. Shape varies per endpoint.
        error:
          $ref: '#/components/schemas/GatewayErrorBody'
    GatewayErrorBody:
      type: object
      description: >
        Machine-readable error classification. EXACTLY ONE of
        terminal/retriable/ ambiguous is true.

        laces_api inferred this from the HTTP status and got it wrong, stranding
        real money twice (2026-08-15, 2026-08-16). Branch on these booleans,
        never on the status code:

          terminal  -> the provider refused. Reverse the debit and tell the user.
          retriable -> safe to retry with the SAME Idempotency-Key.
          ambiguous -> the outcome is UNKNOWN. Do NOT reverse. Poll instead.
      required:
        - code
        - message
        - terminal
        - retriable
        - ambiguous
      properties:
        code:
          type: string
          description: Stable machine code. Branch on this, never on `message`.
          example: payout_rejected_by_provider
        message:
          type: string
          description: >
            Human text from a compiled-in map. NEVER provider text — a
            stringified provider error can carry an Authorization header.
        terminal:
          type: boolean
        retriable:
          type: boolean
        ambiguous:
          type: boolean
        details:
          type: object
          additionalProperties:
            type: string
  responses:
    Unauthorized:
      description: >
        Missing, malformed, stale, replayed or invalid signature. Also returned
        when a body was sent but its raw bytes were unavailable —
        verified-as-empty must never mean processed-as-full.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/APIResult'
  securitySchemes:
    AptaPaySignature:
      type: apiKey
      in: header
      name: X-AptaPay-Signature
      description: >
        `v1={hex HMAC-SHA256}` over the ten-field canonical string. Sent
        alongside `X-AptaPay-Key`, `X-AptaPay-Timestamp` and `X-AptaPay-Nonce` —
        all four are required. OpenAPI can only model one header per scheme, so
        the other three are described in the Authentication section above.

````