APIs
Subscriptions API
Bill a customer every week or every month. They consent once on hosted checkout, and Payer charges their wallet each cycle, retries a miss, and tells you what happened.
A subscription is a recurring charge your customer agrees to once. They pay the first cycle on the hosted checkout page, and that payment is also the standing authorization: from then on Payer opens an invoice for each cycle, charges their wallet, retries if it misses, and notifies you by webhook.
The mental model is the one you already have from card platforms. The difference is where the recurring work lives: you do not schedule anything, and you do not charge anything. Payer owns the clock, the money movement and the retries. Your job is to create the session, then react to events.
Note
There is no POST /subscriptions. A subscription exists only from a paid SUBSCRIPTION checkout session, because the customer's payment is the consent. Everything under /subscriptions reads or ends one.
An integration is four steps:
- 1.
POST /checkout/sessionswithmode: "SUBSCRIPTION"and the recurring terms inprice_data. You get back a hostedurl. - 2.Send your customer to
url. They see the recurring terms, pay the first cycle, and confirm with a one-time code. That code is the authorization for every later cycle. - 3.
subscription.activatedandinvoice.paidland on your webhook endpoint. The subscription isACTIVEand cycle one is paid. - 4.Every cycle after that,
invoice.paid(orinvoice.payment_failed) tells you what happened. You provision, suspend or stop service on those events.
Reading and cancelling subscriptions needs the subscriptions:write scope on a secret key. Deliberately, that scope moves no money: renewals ride the customer's authorization, never your credential. Creating the session uses the same checkout:write credential as a one-off payment. All of it is modelled in the machine-readable OpenAPI spec.
Note
Three key styles, one per direction. Request bodies use snake_case (price_data, at_period_end). Read responses use camelCase (planName, currentPeriodEnd) with ISO 8601 timestamps, because they are projected from GraphQL. Webhook objects use snake_case with Unix timestamps. Map the three separately rather than reusing one model across them.
Create a subscription checkout
POST /checkout/sessionsSame endpoint as a one-off payment, with two extra fields. mode makes it a subscription session, and price_data carries the terms your customer is agreeing to. The session is server-priced: the first payment and every renewal are exactly price_data.amount. Send an Idempotency-Key so a retried create replays the first response instead of standing up a second subscription.
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
mode | string | Yes | SUBSCRIPTION. Omitted or PAYMENT gives you an ordinary one-off session. |
price_data.name | string | Yes | The plan name your customer sees on the consent screen and in the Payer app, e.g. "Pro Monthly". Max 120 characters. |
price_data.amount | integer | Yes | Charged every cycle, in laari (100 laari = 1 MVR). Between 100 and 500000. |
price_data.interval | string | Yes | WEEK or MONTH. |
price_data.interval_count | integer | No | Multiples of the interval between charges. Defaults to 1. The whole span must be at least a week and at most a year, so WEEK × 1 through MONTH × 12. |
title | string | Yes | Shown on the checkout page. Max 255 characters. |
reference | string | Yes | Your own identifier. Must be unique for your account. The subscription inherits it, so it is how you look the subscription up later. |
description | string | No | Extra line under the title. Max 255 characters. |
return_url | string (url) | No | Where the customer is sent after the session reaches a terminal state. |
amount | integer | No | Ignored in subscription mode, where the price is price_data.amount. If you do send it, it must be exactly that value. |
curl https://api.payer.app/checkout/sessions \
-H "Authorization: Bearer sk_test_..." \
-H "Idempotency-Key: sub_customer_10482" \
-H "Content-Type: application/json" \
-d '{
"mode": "SUBSCRIPTION",
"title": "Pro plan",
"reference": "sub_customer_10482",
"return_url": "https://example.com/thanks",
"price_data": {
"name": "Pro Monthly",
"amount": 15000,
"interval": "MONTH",
"interval_count": 1
}
}'{
"id": "9b1c8f2e-3d4a-4f11-a0c2-1e2d3c4b5a69",
"reference": "sub_customer_10482",
"url": "https://api.payer.app/9b1c8f2e-3d4a-4f11-a0c2-1e2d3c4b5a69"
}The response is an ordinary checkout session: redirect the customer to url, and it expires 24 hours after creation if nobody pays it. An unpaid session leaves no subscription behind.
Live subscriptions are enabled per account. Until yours is, a _live_ key gets:
{ "message": "Subscriptions are not enabled for this merchant" }Test keys are ungated, so you can build and test the whole flow in the sandbox before that conversation.
What your customer agrees to
The hosted page shows the plan name, the per-cycle amount and how often it recurs, before the pay button. Paying takes a one-time code sent to their phone, and that code covers the whole arrangement, not just today's payment. There is no second factor on any later cycle: that is the point of the consent.
Two consequences worth designing around:
- The price is frozen at consent. Every cycle charges exactly the
price_datathey approved. Changing a price means a new subscription checkout and cancelling the old subscription. Nothing about a live subscription is editable through the API. - They can cancel at any time from the Payer app, in a tap, with no friction and no second factor. You find out from
subscription.canceled.
Billing periods
The moment the first payment settles is the billing cycle anchor, and every period after it derives from that instant in Maldives time. A subscription anchored on 31 January bills 28 February (29 in a leap year), then 31 March, then 30 April: month ends clamp down, and the anchor day comes back whenever the month is long enough to have it.
One cycle is open at a time. While an invoice is unpaid the next period is never opened, so a subscription that spends three days in retries does not silently owe you two cycles at once.
Subscription statuses
| Status | Meaning |
|---|---|
ACTIVE | Billing normally. There is no "incomplete" state: the wallet settles synchronously, so a subscription exists only from a successful first payment. |
PAST_DUE | A cycle failed and is inside its retry window. Whether you keep serving is your call. |
CANCELLED | Ended, by your customer, by you, by Payer ops, or by exhausted retries. Terminal. |
A PAST_DUE subscription returns to ACTIVE by itself the moment a retry succeeds.
Retrieve a subscription
GET /subscriptions/{id}Returns one of your subscriptions. {id} may be the subscription UUID or the reference you set on the originating checkout session, so you can read it back without having stored a new id. Requires the subscriptions:write scope.
curl https://api.payer.app/subscriptions/sub_customer_10482 \
-H "Authorization: Bearer sk_test_..."{
"id": "1f8a3c22-5e7d-4b90-9a11-6c2e0d4f7b35",
"reference": "sub_customer_10482",
"status": "ACTIVE",
"planName": "Pro Monthly",
"amount": 15000,
"currency": "MVR",
"interval": "MONTH",
"intervalCount": 1,
"checkoutSessionId": "9b1c8f2e-3d4a-4f11-a0c2-1e2d3c4b5a69",
"billingCycleAnchor": "2026-08-14T18:30:00+05:00",
"currentPeriodStart": "2026-08-14T18:30:00+05:00",
"currentPeriodEnd": "2026-09-14T18:30:00+05:00",
"nextBillingAt": "2026-09-14T18:30:00+05:00",
"cancelAtPeriodEnd": false,
"cancelledAt": null,
"cancellationInitiator": null,
"metadata": null,
"createdAt": "2026-08-14T18:30:00+05:00"
}| Field | Type | Notes |
|---|---|---|
reference | string | Inherited from the checkout session that created it. |
planName · amount · interval · intervalCount | The plan snapshot, frozen at consent. amount is in laari. | |
checkoutSessionId | string (uuid) | The session whose first payment created the subscription. |
billingCycleAnchor | string (date-time) | The consent moment every period derives from. |
currentPeriodStart · currentPeriodEnd | string (date-time) | The cycle being served right now. |
nextBillingAt | string (date-time) | When the next renewal is due. null once the subscription ends. |
cancelAtPeriodEnd | boolean | true when it is set to end at currentPeriodEnd without a further charge. |
cancelledAt · cancellationInitiator | string (date-time) · string | When it ended and who ended it: PAYER, MERCHANT, DUNNING or OPS. Both null while it is live. |
A subscription carries no customer identity and no wallet id. You hold a standing charge authorization, not a view into whose wallet satisfies it.
List subscriptions
GET /subscriptionsReturns your subscriptions, newest first, as a JSON array of the object above. Scoped to the key's environment. Requires the subscriptions:write scope.
| Parameter | Type | Description |
|---|---|---|
status | string | Return only ACTIVE, PAST_DUE or CANCELLED subscriptions. |
reference | string | Return only the subscription carrying this reference. |
limit | integer | How many to return. Defaults to 25, capped at 100. |
offset | integer | How many to skip. |
curl "https://api.payer.app/subscriptions?status=PAST_DUE&limit=50" \
-H "Authorization: Bearer sk_test_..."Billing history
GET /subscriptions/{id}/invoicesReturns one invoice per billing cycle, newest first, as a JSON array. The first cycle is written PAID at consent, so the history is complete from day one. {id} may be the UUID or your reference, and limit and offset work as above. Requires the subscriptions:write scope.
[
{
"id": "b3d9e1f4-8c27-4a56-90bd-2f1a7c6e5d40",
"subscriptionId": "1f8a3c22-5e7d-4b90-9a11-6c2e0d4f7b35",
"periodKey": "2026-09-14T18:30:00+05:00",
"periodStart": "2026-09-14T18:30:00+05:00",
"periodEnd": "2026-10-14T18:30:00+05:00",
"amount": 15000,
"currency": "MVR",
"status": "PENDING",
"attemptCount": 1,
"nextRetryAt": "2026-09-15T06:30:00+05:00",
"failureReason": "insufficient-balance",
"paymentIntentId": null,
"paidAt": null,
"createdAt": "2026-09-14T18:30:00+05:00"
}
]| Status | Meaning |
|---|---|
PENDING | The cycle is open and waiting for its charge, first attempt or retry. |
CHARGING | An attempt is in flight. |
PAID | Settled. paymentIntentId is the payment, and paidAt is when it landed. |
FAILED | Every retry was used up. Terminal, and the subscription is cancelled with it. |
VOID | The subscription ended while this cycle was still open. Nothing was charged. |
periodKey is the stable identifier of the period, derived from its start in Maldives time. It is what makes a cycle chargeable exactly once, so it is a good key to store a cycle against on your side. attemptCount counts every attempt including the successful one, and failureReason is why the last one missed: insufficient-balance, blocked-by-rules, wallet-frozen or executor-error.
When a renewal fails
Unlike a mandate charge, a missed renewal is not the end of the story. Payer retries it on a schedule spaced for a wallet top-up rather than for card-network jitter:
- 1.The first attempt runs when the cycle opens. If it misses, the subscription goes
PAST_DUEandsubscription.past_duefires once. - 2.Three retries follow, roughly 12, 24 and 48 hours after the previous attempt. That is four attempts across about three and a half days.
- 3.Each miss fires
invoice.payment_failedcarryingattempt_countandnext_retry_at. - 4.A retry that succeeds pays the cycle, fires
invoice.paid, and the subscription returns toACTIVEon its original anchor. Nothing shifts. - 5.If all four miss, the invoice is
FAILEDand the subscription is cancelled withcancellation_initiator: DUNNING. Winning the customer back means a new subscription checkout.
Your customer is nudged too: Payer warns them in the app before an upcoming charge their balance will not cover, and again on each failure. You do not have to build that.
Tip
PAST_DUE is a service decision, not a payment decision. Keep serving through the retry window if a few days of grace costs you little, or gate access on the first invoice.payment_failed if it does not. Either way, wait for subscription.canceled before tearing anything down.
Cancel a subscription
POST /subscriptions/{id}/cancelEnds a subscription, now or at the period boundary. {id} may be the UUID or your reference. Requires the subscriptions:write scope. Send an Idempotency-Key so a retried cancel is safe.
| Field | Type | Required | Description |
|---|---|---|---|
at_period_end | boolean | No | true keeps the subscription ACTIVE until currentPeriodEnd and then ends it with no further charge. Defaults to false: end it immediately. |
curl -X POST https://api.payer.app/subscriptions/sub_customer_10482/cancel \
-H "Authorization: Bearer sk_test_..." \
-H "Idempotency-Key: cancel_sub_customer_10482" \
-H "Content-Type: application/json" \
-d '{"at_period_end": true}'{
"id": "1f8a3c22-5e7d-4b90-9a11-6c2e0d4f7b35",
"reference": "sub_customer_10482",
"status": "ACTIVE",
"cancelAtPeriodEnd": true,
"cancelledAt": null,
"cancellationInitiator": null,
"currentPeriodEnd": "2026-09-14T18:30:00+05:00"
}Cancelling immediately voids any open unpaid cycle and returns status: "CANCELLED" with cancellationInitiator: "MERCHANT". The current paid period is never refunded, so at_period_end is usually the kinder option: your customer keeps what they paid for, and no further money is taken. Either way they are notified, and subscription.canceled reaches your endpoint.
| Status | Body | Meaning |
|---|---|---|
404 | {"message": "Subscription not found"} | No subscription with that id or reference exists for your account in this key's environment. |
409 | {"message": "Subscription cannot be cancelled"} | It is already cancelled. |
Webhooks
Five events carry the whole lifecycle, so nothing here needs polling. Register an endpoint on the Developers page in your dashboard and subscribe to the ones you need. They use the same envelope, signature and whsec_ secret as checkout events; payload shapes are on the Webhooks page.
| Event | Sent when |
|---|---|
subscription.activated | The first payment settled. The subscription exists and is ACTIVE. |
invoice.paid | A cycle settled, the first one included. |
invoice.payment_failed | A charge attempt missed, with attempt_count and next_retry_at. |
subscription.past_due | The subscription entered its retry window. Fired once per window. |
subscription.canceled | It ended. cancellation_initiator says who ended it. |
Invoice events embed subscription_id and subscription_reference, so you can correlate without a second fetch. checkout.session.completed still fires for the consent session, carrying "mode": "SUBSCRIPTION" and a subscription block with the new subscription's id.
Tip
Provision on invoice.paid, not on subscription.activated. Handling one event the same way for cycle one and cycle fifty is less code, and it is the code that gets exercised every month.
Testing in the sandbox
A sk_test_ key runs the consent half of the flow end to end: create the session, pay it on the sandbox checkout page, then read the subscription and its first invoice back and cancel it. That covers subscription.activated, invoice.paid and subscription.canceled.
Renewals are the part you cannot rush. Cycles come due on real time, so a monthly plan renews a month later, in the sandbox as in production. Write your invoice.paid, invoice.payment_failed and subscription.past_due handlers against the payload shapes on the Webhooks page, and use resend in the delivery log on the Developers page to replay a delivery into them as often as you like.