> ## 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.

# OTP / PIN / 3DS step

> **Finish a charge that came back `requires_action`.** When a collection needs a second step from the customer — an SMS OTP, a momo PIN, a 3-D Secure result — you collect that value in your UI and submit it here against the transaction's gateway reference.

Returns the updated transaction. The status may still be `pending` afterwards: authorizing proves the customer consented, it does not prove the provider has settled, so keep watching the webhook or poll `GET /v1/collections/{reference}`.

**This call validates the code AND charges.** With the upstream provider there is no separate "authorize" step — authorizing re-sends the charge with the code attached, so a correct code debits the customer immediately: they get the handset PIN prompt (STK push), and success or failure arrives by webhook. Expect `pending` here and treat the webhook as the answer.

`pin_id` comes from the `next_action` on the charge (or from `POST /v1/collections/otp` if you drove that yourself); `pin` is what the customer typed. A wrong code fails this call without failing the transaction — prompt again and retry.

**`source` must be resupplied.** Re-sending the charge needs the payer's full number, and the gateway never stores one — only its last 4 digits (so a leaked database cannot become a list of customer phone numbers). The `last4` of what you send is checked against the transaction, so a reference cannot be redirected to a different payer. The amount, currency and country come from the stored transaction and cannot be changed here.




## OpenAPI

````yaml /openapi.json post /v1/collections/{reference}/authorize
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/collections/{reference}/authorize:
    post:
      tags:
        - Collections
      summary: OTP / PIN / 3DS step
      description: >
        **Finish a charge that came back `requires_action`.** When a collection
        needs a second step from the customer — an SMS OTP, a momo PIN, a 3-D
        Secure result — you collect that value in your UI and submit it here
        against the transaction's gateway reference.


        Returns the updated transaction. The status may still be `pending`
        afterwards: authorizing proves the customer consented, it does not prove
        the provider has settled, so keep watching the webhook or poll `GET
        /v1/collections/{reference}`.


        **This call validates the code AND charges.** With the upstream provider
        there is no separate "authorize" step — authorizing re-sends the charge
        with the code attached, so a correct code debits the customer
        immediately: they get the handset PIN prompt (STK push), and success or
        failure arrives by webhook. Expect `pending` here and treat the webhook
        as the answer.


        `pin_id` comes from the `next_action` on the charge (or from `POST
        /v1/collections/otp` if you drove that yourself); `pin` is what the
        customer typed. A wrong code fails this call without failing the
        transaction — prompt again and retry.


        **`source` must be resupplied.** Re-sending the charge needs the payer's
        full number, and the gateway never stores one — only its last 4 digits
        (so a leaked database cannot become a list of customer phone numbers).
        The `last4` of what you send is checked against the transaction, so a
        reference cannot be redirected to a different payer. The amount,
        currency and country come from the stored transaction and cannot be
        changed here.
      operationId: authorizeCollection
      parameters:
        - name: reference
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                authorization:
                  type: object
                  description: >-
                    The provider-agnostic envelope. Branch on the
                    `next_action.type` you were given and post the matching
                    payload back — the wrapper is the same on every rail.
                  properties:
                    type:
                      type: string
                      enum:
                        - otp
                        - pin
                    otp:
                      type: object
                      description: >-
                        `pin_id` identifies which code the customer is
                        answering. `pin` is the code; `code` is accepted as an
                        alias for it.
                      properties:
                        pin_id:
                          type: string
                        pin:
                          type: string
                        code:
                          type: string
                source:
                  $ref: '#/components/schemas/CollectionSource'
                  description: >-
                    REQUIRED for a momo authorize — the payer, resupplied. Its
                    last 4 digits must match the transaction.
                otp:
                  type: object
                  description: Legacy unwrapped form. Still accepted.
                  properties:
                    pin_id:
                      type: string
                    pin:
                      type: string
                pin:
                  type: string
            example:
              authorization:
                type: otp
                otp:
                  pin_id: '{{otp_pin_id}}'
                  pin: '{{otp_pin}}'
              source:
                type: momo
                msisdn: '+256763494235'
                network: MTN
                country: UG
      responses:
        '200':
          description: >-
            The code was accepted and the charge placed. `pending` means the
            customer now has the handset PIN prompt; watch the webhook.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIResult'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
components:
  schemas:
    CollectionSource:
      oneOf:
        - $ref: '#/components/schemas/CollectionMomoSource'
        - $ref: '#/components/schemas/BankDestination'
      discriminator:
        propertyName: type
    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'
    CollectionMomoSource:
      type: object
      description: >
        A momo source on a COLLECTION. `country` is optional here and defaults
        to the request's top-level `country`. Collections pull money: a wrong
        corridor fails or produces a refundable charge, so inheriting is safe.
        The payout side is deliberately stricter — see `PayoutMomoDestination`.
      required:
        - type
        - msisdn
      properties:
        type:
          type: string
          enum:
            - momo
        msisdn:
          type: string
          description: >
            The customer's phone number in whatever form they typed it. All of
            `0772123456`, `256772123456`, `+256772123456` and the bare national
            `772123456` are accepted, with spaces, dashes and parentheses
            ignored. The platform normalises to E.164 WITH the leading `+`
            (`+256772123456`) using the country code of the resolved corridor —
            so a missing country code is supplied, never guessed from the
            digits.

            The number is validated AGAINST the corridor: one carrying a
            different country's calling code, or the wrong national length, is
            rejected with `invalid_msisdn` rather than reinterpreted.
          example: '0772123456'
        network:
          type: string
          description: >
            Network code, a free string from the corridor table — never a closed
            enum, so a network can be added in Firestore with no schema change.
          example: MTN
        country:
          type: string
          description: >
            Optional. Defaults to the request's top-level `country`. Supply it
            only to state the number's country explicitly; when present it is
            still validated against the number, so a mismatch is an error.
          example: UG
    BankDestination:
      type: object
      required:
        - type
        - country
        - bank_code
        - account_number
      properties:
        type:
          type: string
          enum:
            - bank
        country:
          type: string
          example: NG
        bank_code:
          type: string
          example: '044'
        bank_name:
          type: string
        account_number:
          type: string
          example: '0011223344'
        account_name:
          type: string
          description: >
            Advisory only. The gateway RE-RESOLVES the account name at payout
            time, every time — a stale "yes, this is Jane Doe" can outlive the
            account being closed, renamed, or reassigned.
        msisdn:
          type: string
          example: '+256763494235'
          description: >
            The RECIPIENT's phone number. Not needed to reach the account, but
            the upstream provider REQUIRES a contact number on bank payouts and
            rejects an empty one — so a bank payout routed upstream without this
            is refused as `validation_failed` naming `destination.msisdn`.
            Accepted in local format (`0763494235`) and normalised to E.164
            against `destination.country`.
    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:
    BadRequest:
      description: Validation failed.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/APIResult'
    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.

````