Capay Global

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.

FieldTypeDescription
namestring1 to 140 characters.
amountdecimal stringCharged every period, in the plan currency ("5000" is 5,000 XAF).
currency, countryISO codesWhere the money is collected; must be an open corridor for mobile money.
interval, interval_countday | week | month | year, 1 to 12Bill every N intervals. A yearly plan bills at most every 3 years.
trial_daysinteger0 to 365. With a trial the first prompt goes out when it ends.
max_cyclesinteger or nullPaid periods before the subscription completes; null bills until cancelled.
retry_policystandard | gentle | singleWhat happens when a prompt is not approved (see Retries).
metadataobjectYour own keys, returned as sent.

Example request

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

FieldTypeDescription
planstringplan_... id (or uuid) of an active plan in the key's environment.
customer.name, customer.emailstringWho is billed; the name is required (2 to 140 characters).
payment_methodobject or string{ "type": "mobile_money", "msisdn": "+237650000001" } or the number itself. It must belong to the plan's country.
start_atdate-time or nullFirst period start; now when omitted. Never in the past, at most a year ahead.
trial_daysinteger or nullOverrides the plan's trial for this customer.
merchant_referencestringYour reference, unique per app (reusing one answers 422 on merchant_reference).
metadataobjectYour own keys.

Example request

curl
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

201 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

StatusMeaning
pendingCreated; 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).
trialingIn the trial; the first prompt goes out when it ends.
activeThe current period is paid. next_charge_at is the start of the next period.
past_dueA 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.
pausedNothing is prompted until you resume.
cancelled, completedTerminal. 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.

PolicyPrompts again
standardafter 1 hour, 1 day and 3 days (4 prompts per period)
gentleafter 1 day, 3 days and 7 days
singlenever: one prompt per period, then past due

Actions

Every action answers the subscription object.

EndpointEffect
POST /v1/subscriptions/{id}/pauseStops the schedule and withdraws an open prompt the customer has not approved yet. From pending, trialing, active or past_due.
POST /v1/subscriptions/{id}/resumeFrom paused only: re-anchors to now, the next prompt goes out at once.
POST /v1/subscriptions/{id}/cancelCancels now (withdrawing an open prompt), or with { "at_period_end": true } at the end of the paid period. Idempotent.
POST /v1/subscriptions/{id}/retrySends 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}/chargesEvery 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 typeWhen
subscription.createdThe subscription exists (pending or trialing).
subscription.activatedThe first prompt was approved.
subscription.renewedA later period was paid.
subscription.past_dueA renewal prompt was not approved (once when it starts, once more when the retries are exhausted).
subscription.pausedPaused by you.
subscription.resumedResumed by you.
subscription.cancelledCancelled by you, at the period end, or because the first prompt was never approved.
subscription.completedmax_cycles periods are paid.
subscription.renewed
{
  "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
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.