Capay Global

Platform integrations: CRM, ERP and shops

Capay Global is built for platforms that already know the amount and their own deposit id before the customer is ready to pay: a CRM, an ERP, an e-commerce or gaming back office, a billing system. You open a hosted checkout session from your server, send the customer to our page, and get the result twice: as a signed return link in the browser and as a signed callback to your server. Withdrawals go out by your own reference, and a reconciliation report ties every movement back to it. Any platform that can call a REST API can integrate; no plugin on our side is needed.

Who this is for

The surface on this page is one generic layer, so one connector pattern serves every platform of its kind:

  • CRMs that take client deposits and pay withdrawals, for example forex broker CRMs such as FXBO, where the CRM team builds a payment-provider connector against the provider's REST API.
  • ERPs and billing systems that issue an invoice first and need a payable page plus a line-by-line reconciliation file.
  • E-commerce platforms and marketplaces that already run an order id and a success / failure page.
  • Gaming and subscription back offices that credit a wallet after a confirmed deposit.

If your platform can create a collection with the payer's phone number up front, the plain Collections API is enough. Sessions exist for the case where the platform must not collect the phone itself and wants a hosted page, a signed return and a flat callback instead. Collections

How a deposit works

One deposit is one checkout session. The money itself is a regular collection created through the same pipeline as every API collection (intent row first, your company's pricing, ledger on completion), so fees, risk checks, webhooks and settlement behave exactly as documented elsewhere:

  1. Your platform creates the deposit in its own database (id DEP-10482, say) and calls POST /v1/checkout/sessions with the amount, the currency, that id as reference, the customer and your return URLs.
  2. The 201 response carries the session id (cs_...), the hosted url and a one-time return_token. Store the id and the token against the deposit, then send the customer's browser to url.
  3. On pay.capayglobal.com/s/<id> the customer sees your business name and the amount, picks the country and operator (prefilled when you sent prefill), enters the phone and confirms on the handset.
  4. When the collection reaches a terminal status the page forwards the browser to your success, failure or cancel URL with reference, session, status, ts and sig.
  5. In parallel, your callback endpoint receives checkout.session.completed, checkout.session.failed or checkout.session.expired, signed with your endpoint secret.
  6. Your platform credits the deposit once the signature checks out AND the status is confirmed by a GET /v1/checkout/sessions/<id> or by the callback. It credits by session or collection id, never by reference alone.

Session statuses: open (awaiting the payer) -> processing (collection in flight) -> completed | failed | expired | cancelled. A session mirrors the collection it produced; reading an open session past its expires_at marks it expired and emits checkout.session.expired once.

Create a session

POST/v1/checkout/sessions

POST /v1/checkout/sessions needs an sk_ key with the collections:write scope and an Idempotency-Key header (retrying with the same key replays the same response, token included). The environment follows the key: sk_test_ sessions run on the sandbox simulator, sk_live_ sessions move real money.

Request body

FieldTypeDescription
amountstringDecimal string in the currency, for example "5000" for 5,000 XAF (zero-decimal currencies carry no fraction).
currencystringISO 4217 code, 3 letters.
referencestringYour deposit id, 1 to 128 characters. Reserved while a session for it is open or processing: a repeated create for the same reference answers 200 with the existing session (without the return_token).
customerobjectname (max 120), email (max 254), phone (max 20). Shown on the hosted page and copied to the collection.
return_urlsobjectsuccess and failure are required, cancel is optional. https only; IP addresses and localhost are refused. When Capay has set a return-domain allow-list for your business, the host must be a listed domain or a subdomain of one.
prefillobjectcountry (2 letters), method (mobile_money or bank), msisdn. Pre-selected on the hosted page; the payer can change them.
metadataobjectFree-form, at most 4 KB serialized; echoed on reads and copied onto the collection. The keys attempts, event_error and event_error_at are reserved and refused with 422.
expires_in_secondsinteger300 to 86400; default 1800 (30 minutes). After expires_at an unpaid session reads as expired.
curl
curl -X POST https://api.capayglobal.com/v1/checkout/sessions \
  -H "Authorization: Bearer sk_test_XXXXXXXXXXXX" \
  -H "Idempotency-Key: dep-10482-create" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": "5000",
    "currency": "XAF",
    "reference": "DEP-10482",
    "customer": { "name": "Ada Nkemelu", "email": "[email protected]", "phone": "+237670000001" },
    "return_urls": {
      "success": "https://platform.example.com/deposits/DEP-10482/success",
      "failure": "https://platform.example.com/deposits/DEP-10482/failure",
      "cancel": "https://platform.example.com/deposits/DEP-10482/cancel"
    },
    "prefill": { "country": "CM", "method": "mobile_money", "msisdn": "+237670000001" },
    "metadata": { "client_id": "C-88213" },
    "expires_in_seconds": 1800
  }'
201 response
{
  "status": "success",
  "data": {
    "id": "cs_7Qm3k1Xa9Zr4Tb",
    "object": "checkout_session",
    "status": "open",
    "url": "https://pay.capayglobal.com/s/cs_7Qm3k1Xa9Zr4Tb",
    "amount": "5000",
    "currency": "XAF",
    "reference": "DEP-10482",
    "customer": { "name": "Ada Nkemelu", "email": "[email protected]", "phone": "+237670000001" },
    "prefill": { "country": "CM", "method": "mobile_money", "msisdn": "+237670000001" },
    "metadata": { "client_id": "C-88213" },
    "collection": null,
    "expires_at": "2026-10-18T10:45:00Z",
    "completed_at": null,
    "created_at": "2026-10-18T10:15:00Z",
    "livemode": false,
    "return_token": "3f9a2c34b6d14e8a9c217d2f5a1b3c4d5e6f708192a3b4c5"
  },
  "request_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}

The 201 response is the only read that carries return_token (48 hex characters); an idempotent replay with the same Idempotency-Key repeats it, and nothing else ever does. It is the HMAC key of the return link for this one session, so a leaked link can never forge another session's result. Store it with the deposit.

Repeating the create for a reference whose session is still open or processing answers 200 with that session and no token; once the session is terminal the reference is free again and a new create opens a fresh session. A stale open session (past expires_at, no payment started) is expired by the next create with the same reference, which then gets a new session. If your platform also creates payments directly with POST /v1/collections, merchant_reference values starting with cs: are reserved for the gateway (the hosted session writes them) and answer 422.

Errors: 403 checkout_sessions_disabled when Capay has switched sessions off for your business, 409 session_reference_in_use when a concurrent create won the reference (409 request_in_flight when the same Idempotency-Key is reused while the first call runs), 422 validation_error for a malformed field (every refused field is named).

GET/v1/checkout/sessions/{id}

GET/v1/checkout/sessions?reference=&status=

POST/v1/checkout/sessions/{id}/expire

Reads and cancel: GET /v1/checkout/sessions/<id> returns the session with its collection id once the payer has started paying; GET /v1/checkout/sessions?reference= (exact) or ?status= lists newest first with limit and starting_after; POST /v1/checkout/sessions/<id>/expire closes an open session (cancelled) so the page stops accepting payment. The cancel is idempotent (a repeated call answers 200 with the same row) and answers 409 session_not_open for a processing, completed, failed or expired session. No cancel event is emitted: you closed it yourself.

curl
# One session by id (the collection id appears once the payer has started paying)
curl https://api.capayglobal.com/v1/checkout/sessions/cs_7Qm3k1Xa9Zr4Tb \
  -H "Authorization: Bearer sk_test_XXXXXXXXXXXX"

# By your own reference (exact match), or by status
curl "https://api.capayglobal.com/v1/checkout/sessions?reference=DEP-10482" \
  -H "Authorization: Bearer sk_test_XXXXXXXXXXXX"
curl "https://api.capayglobal.com/v1/checkout/sessions?status=open&limit=50" \
  -H "Authorization: Bearer sk_test_XXXXXXXXXXXX"
curl
# Close an open session you no longer want paid (idempotent: a second call answers 200 again)
curl -X POST https://api.capayglobal.com/v1/checkout/sessions/cs_7Qm3k1Xa9Zr4Tb/expire \
  -H "Authorization: Bearer sk_test_XXXXXXXXXXXX"

The hosted page

pay.capayglobal.com/s/<id> shows your trading name and the fixed amount, in the payer's language (the page switches by market and lets the payer choose). The payer picks the country, the operator and the phone number (prefilled from prefill when you sent it; customer.name and customer.email prefill the identity fields). On submit the page calls the public POST /v1/checkout/sessions/initiate with the session id as its only credential, which creates the collection and moves the session to processing. No API key ever reaches the browser.

Mobile money is confirmed on the handset (an STK prompt), or on the operator's hosted page when the corridor uses one; the page polls the collection until it is terminal. A refusal that leaves the session open (corridor closed, provider unavailable, a wrong number) shows an honest screen and lets the payer try again; an expired, cancelled or disabled session shows why and offers the way back to you. When the page is framed inside your own site (see Checkout.js), the signed return is shown as a button instead of a top-level redirect. Checkout.js

The return link and how to verify it

On a terminal status the browser is sent to your success URL (completed), your cancel URL or, when none was given, the failure URL (cancelled), or the failure URL (failed, expired). The query string carries five parameters (appended with & when your URL already has a query):

return link (wrapped for reading)
https://platform.example.com/deposits/DEP-10482/success
  ?reference=DEP-10482
  &session=cs_7Qm3k1Xa9Zr4Tb
  &status=completed
  &ts=1792320360
  &sig=9c1f0e2d3b4a59687766554433221100ffeeddccbbaa99887766554433221100
FieldTypeDescription
referencestringYour deposit id, RFC 3986 percent-encoded (a space is %20, never +).
sessionstringThe session id (cs_...).
statusstringcompleted, failed, expired or cancelled.
tsintegerUnix seconds when the link was built. Reject links older than a few minutes.
sigstringLower-case hex HMAC-SHA256 of "reference|session|status|ts" keyed by the session's return_token.

The signature is computed over the DECODED reference, exactly as you sent it: every web framework decodes the query for you, so hash the value you read, never the raw %20 form. Compare in constant time.

verify by hand
import { createHmac, timingSafeEqual } from 'node:crypto';

// returnToken: the return_token you stored when you created the session.
// url: the full URL the browser arrived on. URLSearchParams DECODES the
// query, so a reference with a space (%20 in the URL) is signed as a space.
export function verifyReturn(url: string, returnToken: string): boolean {
  const q = new URL(url).searchParams;
  const reference = q.get('reference') ?? '';
  const session = q.get('session') ?? '';
  const status = q.get('status') ?? '';
  const ts = q.get('ts') ?? '';
  const sig = q.get('sig') ?? '';
  if (!/^[0-9a-f]{64}$/.test(sig)) return false;
  const expected = createHmac('sha256', returnToken)
    .update(`${reference}|${session}|${status}|${ts}`)
    .digest('hex');
  return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(sig, 'hex'));
}

// Then: GET /v1/checkout/sessions/<session> and credit only when that read
// (or the callback) says completed. The redirect alone never credits a client.
verify with the SDK
# Recompute sig by hand for one return link. TOKEN is the return_token stored
# when the session was created; the message is the DECODED reference, the
# session, the status and ts joined by "|".
TOKEN=3f9a2c34b6d14e8a9c217d2f5a1b3c4d5e6f708192a3b4c5

printf '%s' 'DEP-10482|cs_7Qm3k1Xa9Zr4Tb|completed|1792320360' \
  | openssl dgst -sha256 -hmac "$TOKEN" -r | cut -d' ' -f1
# Compare the printed hex with the sig= value (constant time in real code), then
# confirm the money with a session read: the redirect alone never credits a client.
curl https://api.capayglobal.com/v1/checkout/sessions/cs_7Qm3k1Xa9Zr4Tb \
  -H "Authorization: Bearer sk_test_8fK2q0Zr41Xa"

Never credit a client on the redirect alone, even with a valid signature: a browser can close before it arrives, and a link can be replayed. The signature tells you the customer came back from a real outcome; the money is confirmed by GET /v1/checkout/sessions/<id> (status completed and a collection id) or by the callback. Credit by session or collection id, never by reference alone, so a second session for a reused reference can never double-credit.

Callbacks: standard or simple

Callbacks are the ordinary Capay webhooks: the session events checkout.session.completed, checkout.session.failed and checkout.session.expired, plus collection.*, payout.*, refund.* and transaction.* when you subscribe to them. Each endpoint is created with a format that fixes the body shape of every delivery it receives: standard (the event envelope every SDK speaks) or simple (one flat object with the keys connectors map by name). The signature, the retries and the replay are identical in both.

Register the endpoint once, in the Business Portal (Developers > Webhooks) or through the API. The secret is shown once on creation; store it:

curl
curl -X POST https://api.capayglobal.com/v1/webhook_endpoints \
  -H "Authorization: Bearer sk_test_XXXXXXXXXXXX" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://platform.example.com/capay/callback",
    "event_types": ["checkout.session.completed", "checkout.session.failed", "checkout.session.expired", "payout.completed", "payout.failed"],
    "format": "simple",
    "description": "Deposit and withdrawal callbacks"
  }'

A simple delivery

delivery headers
POST /capay/callback HTTP/1.1
Content-Type: application/json
X-Capay-Event-Id: evt_2n4Kq8Zr41Xa
X-Capay-Event-Type: checkout.session.completed
X-Capay-Format: simple
X-Capay-Signature: t=1792320360,v1=5d41402abc4b2a76b9719d911017c592ab2e5c7e0e6a4b4e1f5d8c9b7a6f5e4d
simple body (checkout.session.completed)
{
  "type": "checkout.session.completed",
  "event_id": "evt_2n4Kq8Zr41Xa",
  "occurred_at": "2026-10-18T10:16:42.876697+00:00",
  "livemode": false,
  "reference": "DEP-10482",
  "session": "cs_7Qm3k1Xa9Zr4Tb",
  "collection": "col_7Qm3k1Xa9Zr4",
  "payout": null,
  "refund": null,
  "status": "completed",
  "amount": "5000",
  "currency": "XAF",
  "fee": "100",
  "net": "4900",
  "country": null,
  "method": null,
  "reason_code": null,
  "merchant_reference": "DEP-10482",
  "fx_rate": null
}

The simple body always carries the same 19 keys (null when a key does not apply); money is a decimal string in the event's currency. For a session event, reference and merchant_reference both repeat your deposit id, fee is the platform fee plus the provider fee, net is amount minus fee, and country, method and reason_code are null. For a collection, payout or refund event the matching id key is filled with status, amount, currency, country, method, reason_code and merchant_reference from the payload; a refund also names its parent collection. fx_rate is reserved and null today. Any other event family keeps every flat key null and adds the payload under data.

The same event in the standard format

standard body (checkout.session.completed)
{
  "id": "evt_2n4Kq8Zr41Xa",
  "object": "event",
  "type": "checkout.session.completed",
  "created_at": "2026-10-18T10:16:42.876697+00:00",
  "livemode": false,
  "data": {
    "session": "cs_7Qm3k1Xa9Zr4Tb",
    "reference": "DEP-10482",
    "collection": "col_7Qm3k1Xa9Zr4",
    "status": "completed",
    "amount_minor": 5000,
    "currency": "XAF",
    "fee_minor": 100,
    "net_minor": 4900,
    "livemode": false
  }
}

In the standard format the session event's data carries the session and collection ids, your reference, the status and the amounts in minor units (amount_minor, fee_minor, net_minor) with the currency.

Every delivery carries X-Capay-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, "{t}.{raw_body}")> plus X-Capay-Event-Id, X-Capay-Event-Type and X-Capay-Format. Verify over the exact bytes received, before any JSON parsing; accept either v1 value (two are present during a secret rotation); reject deliveries whose t is more than 300 seconds away; compare in constant time. The same code verifies both formats:

verify by hand
# T is the t= value from X-Capay-Signature; delivery.json is the raw body as received.
T=1767225600
SECRET=whsec_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

{ printf '%s.' "$T"; cat delivery.json; } \
  | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1
# Compare the printed hex with each v1= value in the header (either may match
# during a secret rotation), and reject the delivery if |now - T| > 300 s.
simple format, with the SDK
# A simple delivery is signed like every webhook: T is the t= value from
# X-Capay-Signature, delivery.json is the raw body exactly as received.
T=1792320360
SECRET=whsec_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

{ printf '%s.' "$T"; cat delivery.json; } \
  | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1
# Compare with each v1= value in the header; reject if |now - T| > 300 s.
# Then read the flat keys: type, event_id, reference, session, collection,
# status, amount, fee, net, currency, merchant_reference (19 keys in all).

Deliveries are at-least-once: key your handler on event_id (X-Capay-Event-Id) and treat a repeat as a no-op that answers 200. Answer 2xx within 10 seconds and do the work afterwards. Callbacks come from no fixed IP address: the signature is the control.

A delivery that does not answer 2xx is delivered up to 8 times in total (the first attempt plus 7 retries) with growing waits: 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h, about 45 hours in all; after that the delivery is marked failed and kept on the endpoint's delivery list, where you can replay the event from the portal or through POST /v1/events/<id>/replay. Details on the Webhooks page. Webhooks

Withdrawals

POST/v1/payouts

GET/v1/payouts?merchant_reference=

A withdrawal is a regular payout: POST /v1/payouts (scope payouts:write, Idempotency-Key required) with the amount, the destination and your own id as merchant_reference. The reference is unique per app, so a repeated withdrawal request can never pay twice: a second create with a reference already used answers 422 validation_error naming merchant_reference, and no payout is created. Your company's payout pricing applies automatically, and payouts at or above your approval threshold wait for a second person in the portal before they dispatch.

curl
curl -X POST https://api.capayglobal.com/v1/payouts \
  -H "Authorization: Bearer sk_test_XXXXXXXXXXXX" \
  -H "Idempotency-Key: wd-55021-create" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": "30000",
    "currency": "XAF",
    "country": "CM",
    "method": "mobile_money",
    "msisdn": "+237670000001",
    "recipient_name": "Ada Nkemelu",
    "merchant_reference": "WD-55021",
    "metadata": { "client_id": "C-88213" }
  }'
curl
# Later: find that withdrawal by your reference (exact match, at most one item)
curl "https://api.capayglobal.com/v1/payouts?merchant_reference=WD-55021" \
  -H "Authorization: Bearer sk_test_XXXXXXXXXXXX"

Look a withdrawal up by its reference with GET /v1/payouts?merchant_reference= (exact match: at most one item, an unknown reference answers an empty list), and follow payout.completed and payout.failed on your callback endpoint; the simple body fills payout, status, amount, currency, reason_code and merchant_reference. Payouts

Reconciliation report

GET/v1/reports/transactions?from&to&kind&format&limit&starting_after

GET /v1/reports/transactions (scope balances:read) returns one row per customer money movement created in the window: every collection, payout and refund, newest first, with kind, id, your reference (the session's reference for a session-created collection, otherwise the merchant_reference you sent; a refund reports its parent collection's), the session id, status, gross, fee, net, currency, fx_rate, country, method, provider, operator_code, reason_code, settlement_payout, batch, created_at and completed_at. The window (from inclusive, to exclusive) is at most 31 days; a longer span answers 422 report_window_invalid, so page by month. kind restricts to collection, payout or refund.

curl
# JSON pages (limit 1..1000, default 100; page with starting_after = last row id)
curl "https://api.capayglobal.com/v1/reports/transactions?from=2026-10-01T00:00:00Z&to=2026-11-01T00:00:00Z&kind=collection&limit=100" \
  -H "Authorization: Bearer sk_test_XXXXXXXXXXXX"
200 response (json)
{
  "status": "success",
  "data": {
    "object": "list",
    "data": [
      {
        "kind": "collection",
        "id": "col_7Qm3k1Xa9Zr4",
        "reference": "DEP-10482",
        "session": "cs_7Qm3k1Xa9Zr4Tb",
        "status": "completed",
        "gross": "5000",
        "fee": "100",
        "net": "4900",
        "currency": "XAF",
        "fx_rate": null,
        "country": "CM",
        "method": "mobile_money",
        "provider": "simulator",
        "operator_code": "MTN",
        "reason_code": null,
        "settlement_payout": null,
        "batch": null,
        "created_at": "2026-10-18T10:15:40Z",
        "completed_at": "2026-10-18T10:16:42Z"
      }
    ],
    "has_more": false,
    "next_starting_after": null
  },
  "request_id": "5c7d9e10-2b34-4a56-9f78-e01a2b3c4d5e"
}

format=csv streams the whole window as one RFC 4180 file (UTF-8, no BOM, CRLF line ends) named capay-transactions-<from>-<to>.csv whose header row lists the same 19 column names; limit and starting_after are ignored. fx_rate is null today: customer movements are single-currency and settlement payouts (your own withdrawals of your balance) are not rows of this report.

curl
# One streamed CSV for the whole window (UTF-8, no BOM, CRLF, RFC 4180)
curl "https://api.capayglobal.com/v1/reports/transactions?from=2026-10-01&to=2026-11-01&format=csv" \
  -H "Authorization: Bearer sk_test_XXXXXXXXXXXX" \
  -o capay-transactions-2026-10-01-2026-11-01.csv
csv (header + one row)
kind,id,reference,session,status,gross,fee,net,currency,fx_rate,country,method,provider,operator_code,reason_code,settlement_payout,batch,created_at,completed_at
collection,col_7Qm3k1Xa9Zr4,DEP-10482,cs_7Qm3k1Xa9Zr4Tb,completed,5000,100,4900,XAF,,CM,mobile_money,simulator,MTN,,,,2026-10-18T10:15:40Z,2026-10-18T10:16:42Z

Sandbox test numbers

Sandbox keys (sk_test_) run every session on the payment simulator and never touch a real operator. The outcome follows the last four digits of the payer's number, so a platform team can script both paths with country CM:

sandbox numbers
+237670000001   # country CM: the deposit completes (processing after ~4 s, completed after ~8 s)
+237670000002   # country CM: the deposit fails with reason_code insufficient_funds

Create your sandbox keys in the Business Portal under Developers > API keys (the Sandbox page lists every scenario, including late success, payer timeout and provider error). The simulator posts the same callbacks, builds the same signed return link and fills the same report rows as production. Sandbox

Field mapping for CRM connectors

Most platform connectors are a form of this table. The left column is what the platform already has; the right column is where it goes on Capay:

On your platformOn CapayNotes
Deposit id / transaction idreference (session), merchant_reference (collection, report)Unique per app while the session is open; the report and every callback repeat it.
Client emailcustomer.emailShown on the hosted page, copied to the collection.
Client namecustomer.nameMax 120 characters.
Client phone (when known)customer.phone and prefill.msisdnPrefills the pay page; the payer can change it.
Client countryprefill.countryISO 3166-1 alpha-2, for example CM, NG, GH.
Amount and currencyamount (decimal string) and currencyNever floats; XAF has no fraction.
Success / failure / cancel pagesreturn_urls.success / failure / cancelhttps; on the allow-list when Capay set one for you.
Any internal ids (client id, account id)metadataEchoed on reads, copied to the collection; at most 4 KB.
PSP session idid (cs_...)Store with the deposit; the return link and the callback carry it as session.
PSP transaction idcollection (col_...)Null until the payer starts paying; the id to credit by.
Return-link secretreturn_tokenShown once on the 201; HMAC key of sig.
Withdrawal idmerchant_reference on POST /v1/payoutsLookup: GET /v1/payouts?merchant_reference=
Fee / net for the client ledgerfee and net on the callback and the reportDecimal strings in the movement's currency.

Status mapping: treat completed as paid; failed, expired and cancelled as closed without money (reason_code says why on failed); open and processing as pending, never as a failure. A deposit with a pending status stays pending until a callback or a GET says otherwise.

Go-live checklist

Production needs a KYB-approved business (see Going live). Once approved: Going live

  1. Create a production key (sk_live_) with collections:write, payouts:write and balances:read, and keep it on your server only.
  2. Register your production callback URL with format simple or standard and store its secret; subscribe to the checkout.session.* and payout.* events.
  3. Send Capay the domains of your return URLs so they can be placed on your allow-list (sessions are on for every business by default; the allow-list is optional and set by Capay operations).
  4. Make one small real deposit end to end: session, pay page, signed return verified, callback verified, GET confirms completed, then the row appears on the reconciliation report.
  5. Make one small real withdrawal by reference and look it up with GET /v1/payouts?merchant_reference=.