Subscriptions and plans
Bill a customer on a schedule. A plan is the price and the cadence; a subscription ties a customer's mobile money number to it. Every period Capay creates an ordinary collection and sends the customer a payment prompt; nothing is charged until they approve it on their phone.
What a subscription is here
No mobile money rail in Africa offers a pull mandate or a card on file. A subscription is therefore not an automatic debit: each renewal is a fresh payment prompt (an STK push) the customer approves within the prompt's expiry, about 30 minutes. If they do not, the plan's retry policy prompts again, then the subscription is marked past due and stops prompting until you retry or cancel.
Every prompt is a collection you already know: it has a col_ id, the same statuses and the same collection.* webhooks, and it lands in your balance and your transaction list like any other payment. The subscription adds the schedule, the retry policy and eight subscription.* events on top.
Only mobile_money plans can be billed today. bank_mandate and card_token are reserved names that answer validation_error until a rail supports them.
Plans
POST /v1/plans creates a plan; GET /v1/plans lists them (status=active|archived); GET /v1/plans/{id} reads one; POST /v1/plans/{id}/archive stops new subscribers (existing subscriptions keep billing). A plan is checked against the open collection corridors of your environment at creation: a country, currency or method Capay cannot collect in answers the routing error and writes nothing.
| Field | Type | Description |
|---|---|---|
| name | string | 1 to 140 characters. |
| amount | decimal string | Charged every period, in the plan currency ("5000" is 5,000 XAF). |
| currency, country | ISO codes | Where the money is collected; must be an open corridor for mobile money. |
| interval, interval_count | day | week | month | year, 1 to 12 | Bill every N intervals. A yearly plan bills at most every 3 years. |
| trial_days | integer | 0 to 365. With a trial the first prompt goes out when it ends. |
| max_cycles | integer or null | Paid periods before the subscription completes; null bills until cancelled. |
| retry_policy | standard | gentle | single | What happens when a prompt is not approved (see Retries). |
| metadata | object | Your own keys, returned as sent. |
Example request
curl -X POST https://api.capayglobal.com/v1/plans \
-H "Authorization: Bearer sk_test_XXXXXXXXXXXX" \
-H "Content-Type: application/json" \
-d '{
"name": "Pro monthly",
"amount": "5000",
"currency": "XAF",
"country": "CM",
"method": "mobile_money",
"interval": "month",
"interval_count": 1,
"trial_days": 0,
"retry_policy": "standard"
}'Idempotency-Key is optional on POST /v1/plans: a plan moves no money.
Create a subscription
POST /v1/subscriptions subscribes a customer to a plan. Without a trial the first prompt goes out at once and the response carries it as latest_charge; with a trial, status is trialing and the first prompt goes out at the end of the trial. Idempotency-Key is required: the request sends a prompt.
| Field | Type | Description |
|---|---|---|
| plan | string | plan_... id (or uuid) of an active plan in the key's environment. |
| customer.name, customer.email | string | Who is billed; the name is required (2 to 140 characters). |
| payment_method | object or string | { "type": "mobile_money", "msisdn": "+237650000001" } or the number itself. It must belong to the plan's country. |
| start_at | date-time or null | First period start; now when omitted. Never in the past, at most a year ahead. |
| trial_days | integer or null | Overrides the plan's trial for this customer. |
| merchant_reference | string | Your reference, unique per app (reusing one answers 422 on merchant_reference). |
| metadata | object | Your own keys. |
Example request
curl -X POST https://api.capayglobal.com/v1/subscriptions \
-H "Authorization: Bearer sk_test_XXXXXXXXXXXX" \
-H "Idempotency-Key: cust-42-pro" \
-H "Content-Type: application/json" \
-d '{
"plan": "plan_4Kq9xZ2mR7tB",
"customer": { "name": "Amina Nkeng", "email": "[email protected]" },
"payment_method": { "type": "mobile_money", "msisdn": "+237650000001" },
"merchant_reference": "cust-42-pro",
"metadata": { "crm_id": "42" }
}'Example response
{
"status": "success",
"data": {
"id": "sub_9pLm2QxT7wVc",
"object": "subscription",
"status": "pending",
"livemode": false,
"plan": { "id": "plan_4Kq9xZ2mR7tB", "name": "Pro monthly", "amount": "5000", "currency": "XAF",
"country": "CM", "interval": "month", "interval_count": 1, "trial_days": 0,
"max_cycles": null, "retry_policy": "standard" },
"customer": { "name": "Amina Nkeng", "email": "[email protected]", "phone": "+237650000001" },
"payment_method": { "id": "pm_2Hs8kD1nQ4Za", "type": "mobile_money", "msisdn": "237650000001",
"country": "CM", "status": "pending_verification" },
"current_period": { "index": 0, "start": "2026-11-01T16:39:34Z", "end": "2026-12-01T16:39:34Z" },
"paid_through": null,
"next_charge_at": "2026-11-01T16:39:34Z",
"next_retry_at": null,
"attempts_in_period": 0,
"dunning_exhausted": false,
"cancel_at_period_end": false,
"cancel_reason": null,
"latest_charge": {
"id": "sch_0CdGBfp0k22Zk9DL3Fxgkj3Z", "period_index": 0, "attempt": 1, "status": "awaiting_payer",
"failure_code": null, "collection": "col_HTqLvA0gl9bAH8raWR0NNtWY", "collection_status": "pending",
"auth_model": "STK_PROMPT", "checkout_url": "https://pay.capayglobal.com/c/col_HTqLvA0gl9bAH8raWR0NNtWY",
"expires_at": "2026-11-01T17:09:37Z"
},
"merchant_reference": "cust-42-pro",
"metadata": { "crm_id": "42" },
"created_at": "2026-11-01T16:39:34Z"
},
"request_id": "8f8395eb-3180-4d97-83cc-b1cecbfbd260"
}The same wallet (country and number) can hold at most 20 live subscriptions with your business; the 21st answers 422 on payment_method.
latest_charge.checkout_url is the hosted page for the prompt: the customer can open it to see what they are approving, and it polls the payment like any payment link page.
Statuses
| Status | Meaning |
|---|---|
| pending | Created; the first prompt has not been approved yet (retries follow the policy; after the last one the subscription is cancelled with cancel_reason first_payment_not_approved). |
| trialing | In the trial; the first prompt goes out when it ends. |
| active | The current period is paid. next_charge_at is the start of the next period. |
| past_due | A renewal prompt was not approved. next_retry_at says when the next prompt goes out; dunning_exhausted true means none is left and you must retry or cancel. |
| paused | Nothing is prompted until you resume. |
| cancelled, completed | Terminal. completed is reached when max_cycles periods are paid. |
Periods
Period 0 starts at start_at (plus the trial). Every period starts exactly one interval after the previous one, computed from the subscription's anchor, never chained: a plan anchored on 31 January bills on 28 February and then again on 31 March. Period N is prompted at its start, in advance. paid_through is the end of the last paid period.
Pausing and resuming re-anchor the schedule to the moment you resume: the next prompt goes out at once and period numbering continues. No proration.
Retries when a prompt is not approved
A prompt the customer does not approve ends failed (payer_timeout or expired) or cancelled (declined). The plan's policy then prompts again, each retry a new attempt on the same period and a new collection. When the policy is exhausted the subscription stays past_due with dunning_exhausted true (or is cancelled, for a first period). Withdrawn prompts (pause, cancel) are not customer refusals and do not count.
| Policy | Prompts again |
|---|---|
| standard | after 1 hour, 1 day and 3 days (4 prompts per period) |
| gentle | after 1 day, 3 days and 7 days |
| single | never: one prompt per period, then past due |
Actions
Every action answers the subscription object.
| Endpoint | Effect |
|---|---|
| POST /v1/subscriptions/{id}/pause | Stops the schedule and withdraws an open prompt the customer has not approved yet. From pending, trialing, active or past_due. |
| POST /v1/subscriptions/{id}/resume | From paused only: re-anchors to now, the next prompt goes out at once. |
| POST /v1/subscriptions/{id}/cancel | Cancels now (withdrawing an open prompt), or with { "at_period_end": true } at the end of the paid period. Idempotent. |
| POST /v1/subscriptions/{id}/retry | Sends the next prompt now for a past_due subscription (or a pending one whose first prompt failed). 422 on field charge while a prompt is open. Idempotency-Key required. |
| GET /v1/subscriptions/{id}/charges | Every prompt, one row per period and attempt, with its collection and outcome. |
A prompt the customer has already approved (collection processing) cannot be withdrawn: the money arrives, the charge is marked paid, the subscription does not resurrect, and the payment is refundable with POST /v1/refunds.
Webhooks
The eight subscription.* events carry the full subscription object under data (the same shape as GET /v1/subscriptions/{id}, without charges). The prompt's own collection.* events arrive as well; the two streams can reach you in either order.
| Event type | When |
|---|---|
| subscription.created | The subscription exists (pending or trialing). |
| subscription.activated | The first prompt was approved. |
| subscription.renewed | A later period was paid. |
| subscription.past_due | A renewal prompt was not approved (once when it starts, once more when the retries are exhausted). |
| subscription.paused | Paused by you. |
| subscription.resumed | Resumed by you. |
| subscription.cancelled | Cancelled by you, at the period end, or because the first prompt was never approved. |
| subscription.completed | max_cycles periods are paid. |
{
"id": "evt_5p2Kq8Zr41Xb",
"object": "event",
"type": "subscription.renewed",
"created_at": "2026-12-01T16:40:12.260Z",
"livemode": false,
"data": {
"id": "sub_9pLm2QxT7wVc",
"object": "subscription",
"status": "active",
"current_period": { "index": 1, "start": "2026-12-01T16:39:34Z", "end": "2027-01-01T16:39:34Z" },
"paid_through": "2027-01-01T16:39:34Z",
"next_charge_at": "2027-01-01T16:39:34Z",
"latest_charge": { "period_index": 1, "attempt": 1, "status": "paid", "collection": "col_l4HMmNoxrjqZFuElZfWo1MOT" },
"actor": "collection"
}
}Sandbox
Sandbox prompts follow the simulator: a number ending in 0001 approves every prompt, 0004 lets every prompt time out, and X-Scenario-Key on POST /v1/subscriptions pins one scenario for the whole subscription. POST /v1/subscriptions/{id}/advance (or POST /v1/test/advance with resource_type subscription) is the test clock: the next prompt falls due now, so a monthly plan is walked through in minutes. Production keys answer 403 sandbox_only.
curl -X POST https://api.capayglobal.com/v1/subscriptions/sub_9pLm2QxT7wVc/advance \ -H "Authorization: Bearer sk_test_XXXXXXXXXXXX"
Scopes
Reads need subscriptions:read; plans, subscriptions and their actions need subscriptions:write. Keys created before this release do not carry the scopes: add them under Developers > API keys.