Payment links & hosted checkout
A payment link turns a collection into a URL. You create the link in the Business Portal; your customer opens it, enters their details, and pays on our hosted checkout at pay.capayglobal.com: no backend code, no API key in the browser, and no second money path. The checkout creates a regular collection through the exact same /v1 pipeline your servers would use, so fees, risk checks, webhooks and settlement behave identically.
How it works
A link is a template, never money. The collection only exists once a payer submits the checkout form:
- You create a link in the Business Portal (Payment links) and share its URL.
- The payer opens pay.capayglobal.com/l/<pl_id>. The page shows your trading name, the title and the amount (or an amount field on an open-amount link) and asks for the payer's name, email and phone.
- The page calls POST /v1/checkout/initiate, which creates a collection through the standard create pipeline: routing, limits, fees, risk, then provider dispatch.
- The payer completes the payment: a redirect to the provider's hosted page (auth_model REDIRECT), or an STK prompt confirmed on their phone (STK_PROMPT).
- The pay page polls GET /v1/checkout/status/{id} until the collection is terminal, then shows a receipt (and forwards to your success_url when the link has one).
- You get the same collection.* webhooks as for any API-created collection, and the money settles into your balance the same way.
https://pay.capayglobal.com/l/pl_9Xw2mB4qR7Ta
Because the checkout runs handleCreateCollection unchanged, everything on the Collections reference page applies: the object shape, the status lifecycle, reason codes and webhooks. Collections
Creating a link
Links are created and managed in the Business Portal under Payment links (there is no /v1 endpoint for creating links today). A link carries a title, a description, a currency, a country, a method (mobile_money or bank), an optional expiry date, and an optional https success_url the payer is sent to after paying. Each link can be disabled and re-enabled at any time; a disabled or expired link shows the payer an honest unavailable screen and creates nothing.
The amount is either fixed (every payer pays exactly that amount; anything the payer types is ignored) or open: the payer enters the amount, optionally bounded by a minimum and maximum you set on the link.
Sandbox links resolve through the payment simulator and never touch a real operator. Production links only accept payments while your merchant account is ACTIVE (KYB approved): a production link for a not-yet-verified merchant shows as unavailable. See Going live.
POST /v1/checkout/initiate
POST/v1/checkout/initiate
The endpoint the hosted checkout calls when the payer submits the form. It carries NO API key: the link id is the credential, and the server synthesizes the same authorization an sk_ key for that app would have, scoped to collections:write only. You normally never call it yourself, but the contract is public and stable:
Request body
| Field | Type | Description |
|---|---|---|
| link | string | The payment link id (pl_...). The link must be active, not expired, and belong to an active app and a merchant that may collect. |
| name | string | Payer name, 2 to 140 characters. |
| string | Payer email address (max 254 characters). | |
| phone | string | Payer phone in international format: 8 to 15 digits, with an optional leading + or 00; spaces, dots, dashes and parentheses are stripped. |
| amount | string | Decimal string in the link currency. Required for an open-amount link (bounded by the link min/max); ignored on a fixed-amount link. |
An Idempotency-Key header is optional here (the one place on /v1 where a money-creating POST does not require it): the checkout sends one key per form attempt so a double submit replays the same collection instead of prompting the payer twice. Every attempt gets its own unique merchant_reference of the form pl:<link_id>:<attempt_id> regardless.
The endpoint is rate-limited per source IP: 10 requests per minute by default. Every attempt counts, valid or not, and an over-limit call returns 429 ip_rate_limited with a Retry-After header. Card data anywhere in the body is rejected with card_data_rejected and never stored.
curl -X POST https://api.capayglobal.com/v1/checkout/initiate \
-H "Content-Type: application/json" \
-H "Idempotency-Key: chk-attempt-5f2c7a10b8" \
-d '{
"link": "pl_9Xw2mB4qR7Ta",
"name": "Ada Nkemelu",
"email": "[email protected]",
"phone": "+237650000001",
"amount": "5000"
}'A successful call returns 201 with the serialized collection (see the Collections reference), plus the hosted_url when the provider answered a redirect page, the checkout_url of our own pay page for this collection, the link's success_url, and the link id and title:
{
"status": "success",
"data": {
"collection": {
"id": "col_7Qm3k1Xa9Zr4",
"object": "collection",
"status": "pending",
"amount": "5000",
"currency": "XAF",
"country": "CM",
"method": "mobile_money",
"msisdn": "237650000001",
"merchant_reference": "pl:0f9a2c34-6b1d-4e8a-9c21-7d2f5a1b3c4d:3fa85f64-5717-4562-b3fc-2c963f66afa6",
"customer": {
"name": "Ada Nkemelu",
"email": "[email protected]",
"phone": "237650000001",
"source": "payment_link"
},
"metadata": {
"payment_link": "pl_9Xw2mB4qR7Ta",
"payment_link_title": "Order 10482",
"statement_description": "Sunrise Electronics"
},
"fees": {
"platform_fee": "100",
"provider_fee": "0",
"total_fee": "100",
"bearer": "merchant"
},
"net_amount": null,
"auth_model": "STK_PROMPT",
"hosted_url": null,
"reason_code": null,
"requires_requery": false,
"provider": "simulator",
"operator": "MTN",
"livemode": false,
"created_at": "2026-08-30T10:15:00Z",
"completed_at": null,
"expires_at": "2026-08-30T10:45:00Z"
},
"hosted_url": null,
"checkout_url": "https://pay.capayglobal.com/c/col_7Qm3k1Xa9Zr4",
"success_url": "https://merchant.example.com/thanks",
"link": { "id": "pl_9Xw2mB4qR7Ta", "title": "Order 10482" }
},
"request_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}hosted_url redirect vs STK prompt
When the route resolves to a hosted-page provider, the collection comes back with auth_model REDIRECT and a non-null hosted_url. Send the payer's browser to hosted_url; they pick their operator and confirm there, then return to the checkout. While the collection is still in flight the hosted_url stays reachable from the pay page, so a payer who closed the tab can resume.
When the route resolves to a direct mobile money rail, auth_model is STK_PROMPT and hosted_url is null: the operator pushes a confirmation prompt to the payer's phone and there is nothing to redirect to. The payer stays on the pay page (checkout_url), approves the prompt with their PIN, and the page's status poll picks the outcome up. Which continuation you get depends on the corridor and current routing, so always branch on auth_model rather than assuming one.
GET /v1/checkout/status/{id}
GET/v1/checkout/status/{id}
The pay page's live poll, also public: no API key, keyed on the unguessable collection public id (col_...), and limited to 90 requests per minute per IP. It answers a deliberately small, payer-safe projection:
curl https://api.capayglobal.com/v1/checkout/status/col_7Qm3k1Xa9Zr4
{
"status": "success",
"data": {
"status": "completed",
"reason_code": null,
"receipt_ref": "18563064797",
"requeried": true
},
"request_id": "5c7d9e10-2b34-4a56-9f78-e01a2b3c4d5e"
}Response data fields
| Field | Type | Description |
|---|---|---|
| status | string | The collection status, with expiry folded in: an in-flight collection past its expires_at reads as expired. |
| reason_code | string | null | The failure reason, present only on failed and cancelled outcomes. See the reason-code vocabulary on the Errors page. |
| receipt_ref | string | null | Set on completed only: the receipt reference to show the payer (see below). |
| requeried | boolean | True when this very call refreshed the status from the provider rather than serving the stored one. |
Mobile money truth normally arrives by requery, and the background reconcilers tick about once a minute: too slow for a payer who has just entered their PIN. So for an in-flight production collection less than 45 minutes old, this endpoint re-fetches the provider's status inline (capped at 8 seconds) and applies it through the same idempotent transition path the reconcilers use. Poll it every few seconds until the status is terminal.
The receipt is the operator's transaction id
On completion, receipt_ref is the OPERATOR's own transaction id: the same number the payer sees in their mobile money SMS (for example an MTN financial transaction id). Showing that number is what makes the receipt verifiable against the payer's phone. When the operator reference is not known, receipt_ref falls back to the collection public id. The same value is stored on the collection and visible in the portal.
Tracking what a link collected
Every collection born from a link names it in metadata.payment_link and carries the link title in metadata.payment_link_title, so webhooks and exports can be grouped per link. The link's collected count and collected total move only when a collection COMPLETES, never on creation, and the portal list shows both live. The payer prompt and statement carry your trading name by default (metadata.statement_description), so the payer sees your business, not the gateway.
Errors
Link problems answer payer-facing codes of their own; everything past the link check is the standard collection create pipeline, so its whole error table (corridor_closed, provider_unavailable, risk_rejected, limit_exceeded and the rest) applies unchanged. The initiate-specific codes:
| Code | HTTP | Returned when |
|---|---|---|
| validation_error | 422 | A body field is missing or malformed (link, name, email, phone), or amount is missing, out of the link's bounds, or invalid for the currency. |
| link_not_found | 404 | No payment link matches this id, or its app or merchant is gone. |
| link_disabled | 410 | The link was disabled, its app is inactive, or the merchant may not collect (suspended, closed, or not yet KYB-approved on a production link). |
| link_expired | 410 | The link's expires_at has passed. |
| ip_rate_limited | 429 | More than the per-IP allowance (10/min on initiate, 90/min on the status poll). Retry-After says when to come back. |
| card_data_rejected | 422 | The body contained card data (PAN, CVV or expiry). Nothing is stored. |
{
"status": "error",
"error": {
"type": "rate_limit_error",
"code": "ip_rate_limited",
"message": "Too many requests from this network address.",
"next_step": "Back off and retry after the window resets. Authenticate with a valid API key for higher limits.",
"request_id": "7e1f0a92-6c53-4b8d-a041-2f9c8e7d6b5a"
}
}