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

# Collect a payment

> Charge a customer's mobile money wallet, bank account or card, and learn when the money has actually arrived.

A collection is the money-in entry point: what your app calls when a user taps "Pay". You hand over an amount, a country, a method and the payer's identifier, and the gateway asks the routed provider to debit them. Typical uses are a checkout, a wallet top-up or an entry fee.

The customer-facing prompt — the mobile money PIN, the redirect page, the SMS code — belongs to the provider. You get back a canonical status to branch on instead of a provider-shaped payload.

<Warning>
  A charge does not settle synchronously. `POST /v1/collections` returns `201` with `status: "pending"`, which means the request reached the provider, not that the customer has paid. Treat the webhook, or a poll of `GET /v1/collections/{reference}`, as the real answer. Never treat the `201` as settlement.
</Warning>

## Before you start

* **Amounts are integer minor units.** Every amount field ends in `_minor`. UGX, RWF, XAF and XOF are zero-decimal, so 2,000 UGX is `2000`, not `200000`. Getting this wrong is a 100x error. See [Money and minor units](/concepts/money).
* **Check the corridor.** Capability is per `(country, currency, direction, method)`. Call `GET /v1/reference/corridors` and render your UI against it.
* **`Idempotency-Key` is required.** This call moves money. See [Idempotency](/concepts/idempotency).

## The flow

<Steps>
  <Step title="Quote the charge (optional)">
    `POST /v1/collections/quote` returns the fee and the total the customer must pay, so a confirm screen can show real numbers before anyone commits.

    This is a standalone call that holds no state. It moves no money, needs no `Idempotency-Key`, and returns no token to redeem — quote as often as you like, or skip it entirely. A quote is never "used up" and can never expire out from under a customer.

    Show `total_to_pay_minor` rather than adding `amount_minor` and `fee_minor` yourself. That figure comes from the provider and is what the customer is actually billed.

    ```bash Quote a collection theme={null}
    curl -sS https://api.pay.aptahq.com/v1/collections/quote \
      -H "X-AptaPay-Key: $APP_ID.$KEY_ID.$API_KEY" \
      -H "X-AptaPay-Timestamp: $TS" \
      -H "X-AptaPay-Nonce: $NONCE" \
      -H "X-AptaPay-Signature: v1=$SIG" \
      -H "Content-Type: application/json" \
      -d '{
        "amount_minor": 2000,
        "currency": "UGX",
        "country": "UG",
        "method": "momo"
      }'
    ```

    `momo` and `bank` can be quoted. `card` cannot, and answers `501`.
  </Step>

  <Step title="Charge the customer">
    One call. Send the amount, the corridor and the payer's number.

    ```bash Charge a Uganda mobile money wallet theme={null}
    curl -sS https://api.pay.aptahq.com/v1/collections \
      -H "X-AptaPay-Key: $APP_ID.$KEY_ID.$API_KEY" \
      -H "X-AptaPay-Timestamp: $TS" \
      -H "X-AptaPay-Nonce: $NONCE" \
      -H "X-AptaPay-Signature: v1=$SIG" \
      -H "Idempotency-Key: 6b1f2c34-8a0e-4d1b-9f77-2c5a8e0b4d31" \
      -H "Content-Type: application/json" \
      -d '{
        "amount_minor": 2000,
        "currency": "UGX",
        "country": "UG",
        "method": "momo",
        "source": {
          "type": "momo",
          "msisdn": "0781234567",
          "network": "MTN",
          "country": "UG"
        },
        "narration": "Race entry",
        "metadata": { "order_id": "ord_8813" }
      }'
    ```

    The response carries a gateway `reference`. Persist it: it is your handle for polling, for the webhook you will receive, and for a later refund.

    ```json Response theme={null}
    {
      "code": 201,
      "message": "OK",
      "data": {
        "reference": "APT-LCS-oRUZWq8fyn9DttNb3SN3B",
        "provider": "eversend",
        "status": "pending",
        "observed_amount_minor": 2000,
        "observed_currency": "UGX"
      }
    }
    ```

    The customer now sees a prompt on their handset and approves it there.
  </Step>

  <Step title="Branch on next_action">
    Most charges go straight to the provider and come back `pending`. Some corridors need a second step from the customer first, and those come back `status: "requires_action"` with a `next_action` object.

    Always branch on the presence of `next_action` rather than assuming it is absent. That way your integration keeps working if a corridor changes, or if you deploy against a provider that does require a step.

    | `next_action.type` | What to do                                              |
    | ------------------ | ------------------------------------------------------- |
    | absent             | Nothing. Wait for the webhook.                          |
    | `redirect`         | Send the customer to `next_action.redirect_url`.        |
    | `otp`              | Collect the code and post it to the authorize endpoint. |

    **Ghana (GHS) requires `redirect_url` on the request** and answers `requires_action` with a `next_action.redirect_url`. Send the user there to finish.
  </Step>

  <Step title="Wait for the real answer">
    Your webhook handler is the primary signal and the source of truth for settlement. See [Webhooks](/concepts/webhooks).

    As a fallback, poll `GET /v1/collections/{reference}`. By default that reads the gateway's own ledger — a database read, not a provider call — so polling is cheap and safe.

    **Pass `live=true` when a customer is watching a payment screen.** The gateway then asks the provider what it currently holds and applies the answer before responding, so a charge the customer just approved is reported as `successful` on that very poll rather than a minute later. Upstream calls are collapsed to at most one per reference every few seconds however many callers ask, so polling on a timer and from several tabs at once is safe.

    ```bash Poll with a live upstream check theme={null}
    curl -sS "https://api.pay.aptahq.com/v1/collections/APT-LCS-oRUZWq8fyn9DttNb3SN3B?live=true" \
      -H "X-AptaPay-Key: $APP_ID.$KEY_ID.$API_KEY" \
      -H "X-AptaPay-Timestamp: $TS" \
      -H "X-AptaPay-Nonce: $NONCE" \
      -H "X-AptaPay-Signature: v1=$SIG"
    ```

    A live check never changes the response shape and never fails the read. An unreachable provider, an unlookupable transaction, an already-terminal record or a caller inside the cooldown all return the stored record with a `200`. The only difference is that the status may be fresher.

    | `status`          | Meaning                                       |
    | ----------------- | --------------------------------------------- |
    | `pending`         | Keep waiting.                                 |
    | `requires_action` | The customer still owes an OTP or a redirect. |
    | `successful`      | Terminal. The money arrived.                  |
    | `failed`          | Terminal. It did not.                         |
  </Step>
</Steps>

## A complete Node example

```javascript collect.js theme={null}
import { randomUUID } from "node:crypto";
import { signedFetch } from "./aptapay-client.js";

const BASE = "https://api.pay.aptahq.com";

// 1. Charge. The Idempotency-Key is generated once per user intent,
//    not once per HTTP attempt — reuse it if you retry.
const idempotencyKey = randomUUID();

const charge = await signedFetch(`${BASE}/v1/collections`, {
  method: "POST",
  headers: { "Idempotency-Key": idempotencyKey },
  body: {
    amount_minor: 2000, // UGX is zero-decimal: 2,000 shillings
    currency: "UGX",
    country: "UG",
    method: "momo",
    source: { type: "momo", msisdn: "0781234567", network: "MTN", country: "UG" },
    narration: "Race entry",
    metadata: { order_id: "ord_8813" },
  },
});

const { reference, status, next_action: nextAction } = charge.data;
await saveOrderReference("ord_8813", reference);

// 2. Branch on next_action rather than assuming it is absent.
if (nextAction?.type === "redirect") {
  return redirectCustomerTo(nextAction.redirect_url);
}
if (nextAction?.type === "otp") {
  return promptForOtp({ reference, pinId: nextAction.pin_id });
}

// 3. `pending` is the normal answer. The webhook settles it; this poll
//    is what keeps the waiting customer informed in the meantime.
if (status === "pending") {
  const fresh = await signedFetch(
    `${BASE}/v1/collections/${reference}?live=true`,
    { method: "GET" },
  );
  return fresh.data.status;
}
```

## Correlate on metadata, not on the reference

Put your own order id in `metadata` at charge time. It is stored on the transaction and echoed back on every webhook delivery as `data.metadata`, which is how you map a gateway reference to one of your own.

Our `reference` is opaque. It looks like `APT-LCS-oRUZWq8fyn9DttNb3SN3B`, and while that shape is `APT-{prefix}-{nanoid}` today, it carries no field you are meant to parse.

<Warning>
  Never parse the `reference` to recover your own identifiers. Match on `data.metadata` instead. `metadata` must serialize to at most 4096 bytes, because it is re-sent on every delivery attempt.
</Warning>

## OTP is not required

This account is whitelisted for phone verification, so **no corridor currently requires an OTP**. `requires_otp` is false for every corridor. A mobile money charge goes to the provider on the single `POST /v1/collections` call and the customer approves it on their handset prompt alone — Kenya, Rwanda, Uganda and Ghana alike. There is nothing to collect and nothing to post back.

The ladder behind it is retained, and can be re-enabled per corridor through configuration. If a corridor is configured to demand a code, a charge sent without an `otp` texts the customer one, records the transaction, and answers `201` with `status: "requires_action"` and a `next_action` of type `otp` carrying a `pin_id`.

<AccordionGroup>
  <Accordion title="Finishing a charge that came back requires_action">
    Collect the code in your UI and submit it against the transaction's reference. `pin_id` comes from the `next_action` on the charge; `pin` is what the customer typed.

    ```bash Authorize with the customer's code theme={null}
    curl -sS https://api.pay.aptahq.com/v1/collections/APT-LCS-oRUZWq8fyn9DttNb3SN3B/authorize \
      -H "X-AptaPay-Key: $APP_ID.$KEY_ID.$API_KEY" \
      -H "X-AptaPay-Timestamp: $TS" \
      -H "X-AptaPay-Nonce: $NONCE" \
      -H "X-AptaPay-Signature: v1=$SIG" \
      -H "Content-Type: application/json" \
      -d '{
        "otp": { "pin_id": "a435fdbc-a0b3-4412-848c-2dbc0956e5c6", "pin": "445123" },
        "source": {
          "type": "momo",
          "msisdn": "0781234567",
          "network": "MTN",
          "country": "UG"
        }
      }'
    ```

    **This call validates the code and charges.** There is no separate authorize step upstream: authorizing re-sends the charge with the code attached, so a correct code debits the customer immediately. Expect `pending` here too, and treat the webhook as the answer. A wrong code fails this call without failing the transaction — prompt again and retry.

    **You must resupply `source`.** Re-sending the charge needs the payer's full number, and the gateway never stores one — only its last four 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. Amount, currency and country come from the stored transaction and cannot be changed here.
  </Accordion>

  <Accordion title="Requesting an OTP yourself">
    You almost certainly do not need this. `POST /v1/collections/otp` triggers an SMS one-time PIN for a mobile money collection and is kept for corridors configured to demand a code, and for deployments on an un-whitelisted account.

    When you do need it: call it first, show the user an "enter the code we texted you" field, then pass what they type back on the charge. It returns a `pin_id`; send it with the code as `otp: {pin_id, pin}` — on `POST /v1/collections` for a fresh charge, or on the authorize endpoint for one already sitting at `requires_action`.

    No money moves, so no `Idempotency-Key`. Calling it again simply sends another code.
  </Accordion>
</AccordionGroup>

## The double-charge guard

Before charging, the gateway checks whether a mobile money charge to this same number is already live for your tenant within the last 15 minutes. If one is, it asks the provider what actually happened.

| Earlier charge                             | Result                                                                                                                               |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| Succeeded, but its webhook was lost        | `409 duplicate_charge_in_flight` with `already_succeeded: "true"` and the earlier `reference`. Show that as a receipt; do not retry. |
| Failed or expired                          | It is settled, and this charge proceeds normally.                                                                                    |
| Still live, or the provider is unreachable | `409 duplicate_charge_in_flight`. Wait and poll `GET /v1/collections/{reference}`.                                                   |

This is separate from `Idempotency-Key`. That protects a retried API call; this protects a retried **customer** action, which arrives with a brand-new key.

## Phone numbers

Send the number in whatever form the customer typed. All of `0772123456`, `256772123456`, `+256772123456` and the bare national `772123456` are accepted, with spaces, dashes and parentheses ignored.

The gateway normalises to E.164 with a leading `+` using the country code of the **resolved corridor**, so a missing country code is supplied rather than guessed from the digits. On a collection, `source.country` is optional and defaults to the request's top-level `country`.

## Errors worth handling

| Code                         | Meaning                                                                     |
| ---------------------------- | --------------------------------------------------------------------------- |
| `duplicate_charge_in_flight` | A charge to this number is already live. See above.                         |
| `unknown_currency`           | The currency has no declared exponent. Never defaulted.                     |
| `inexact_amount`             | The minor amount is not an exact integer for that currency.                 |
| `invalid_msisdn`             | The number is not valid for the resolved corridor.                          |
| `mode_mismatch`              | A body `mode` field disagreed with the key. See [Testing](/guides/testing). |

Full list and response shape: [Errors](/concepts/errors).

## Next steps

<Columns cols={2}>
  <Card title="Send a payout" icon="arrow-up-from-line" href="/guides/payouts">
    The money-out side, and how float and fees work.
  </Card>

  <Card title="Test your integration" icon="flask-conical" href="/guides/testing">
    Test keys, the amount ceiling, and the Postman collection.
  </Card>
</Columns>
