Browse documentation

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.

View as Markdown

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. 1.POST /checkout/sessions with mode: "SUBSCRIPTION" and the recurring terms in price_data. You get back a hosted url.
  2. 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. 3.subscription.activated and invoice.paid land on your webhook endpoint. The subscription is ACTIVE and cycle one is paid.
  4. 4.Every cycle after that, invoice.paid (or invoice.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/sessions
POST /checkout/sessions

Same 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

FieldTypeRequiredDescription
modestringYesSUBSCRIPTION. Omitted or PAYMENT gives you an ordinary one-off session.
price_data.namestringYesThe plan name your customer sees on the consent screen and in the Payer app, e.g. "Pro Monthly". Max 120 characters.
price_data.amountintegerYesCharged every cycle, in laari (100 laari = 1 MVR). Between 100 and 500000.
price_data.intervalstringYesWEEK or MONTH.
price_data.interval_countintegerNoMultiples 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.
titlestringYesShown on the checkout page. Max 255 characters.
referencestringYesYour own identifier. Must be unique for your account. The subscription inherits it, so it is how you look the subscription up later.
descriptionstringNoExtra line under the title. Max 255 characters.
return_urlstring (url)NoWhere the customer is sent after the session reaches a terminal state.
amountintegerNoIgnored in subscription mode, where the price is price_data.amount. If you do send it, it must be exactly that value.
Request
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
    }
  }'
200 OK
{
  "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:

403 Forbidden
{ "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_data they 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

StatusMeaning
ACTIVEBilling normally. There is no "incomplete" state: the wallet settles synchronously, so a subscription exists only from a successful first payment.
PAST_DUEA cycle failed and is inside its retry window. Whether you keep serving is your call.
CANCELLEDEnded, 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}
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.

Request
curl https://api.payer.app/subscriptions/sub_customer_10482 \
  -H "Authorization: Bearer sk_test_..."
200 OK
{
  "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"
}
FieldTypeNotes
referencestringInherited from the checkout session that created it.
planName · amount · interval · intervalCountThe plan snapshot, frozen at consent. amount is in laari.
checkoutSessionIdstring (uuid)The session whose first payment created the subscription.
billingCycleAnchorstring (date-time)The consent moment every period derives from.
currentPeriodStart · currentPeriodEndstring (date-time)The cycle being served right now.
nextBillingAtstring (date-time)When the next renewal is due. null once the subscription ends.
cancelAtPeriodEndbooleantrue when it is set to end at currentPeriodEnd without a further charge.
cancelledAt · cancellationInitiatorstring (date-time) · stringWhen 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 /subscriptions
GET /subscriptions

Returns your subscriptions, newest first, as a JSON array of the object above. Scoped to the key's environment. Requires the subscriptions:write scope.

ParameterTypeDescription
statusstringReturn only ACTIVE, PAST_DUE or CANCELLED subscriptions.
referencestringReturn only the subscription carrying this reference.
limitintegerHow many to return. Defaults to 25, capped at 100.
offsetintegerHow many to skip.
Request
curl "https://api.payer.app/subscriptions?status=PAST_DUE&limit=50" \
  -H "Authorization: Bearer sk_test_..."

Billing history

GET /subscriptions/{id}/invoices
GET /subscriptions/{id}/invoices

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

200 OK
[
  {
    "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"
  }
]
StatusMeaning
PENDINGThe cycle is open and waiting for its charge, first attempt or retry.
CHARGINGAn attempt is in flight.
PAIDSettled. paymentIntentId is the payment, and paidAt is when it landed.
FAILEDEvery retry was used up. Terminal, and the subscription is cancelled with it.
VOIDThe 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. 1.The first attempt runs when the cycle opens. If it misses, the subscription goes PAST_DUE and subscription.past_due fires once.
  2. 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. 3.Each miss fires invoice.payment_failed carrying attempt_count and next_retry_at.
  4. 4.A retry that succeeds pays the cycle, fires invoice.paid, and the subscription returns to ACTIVE on its original anchor. Nothing shifts.
  5. 5.If all four miss, the invoice is FAILED and the subscription is cancelled with cancellation_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}/cancel
POST /subscriptions/{id}/cancel

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

FieldTypeRequiredDescription
at_period_endbooleanNotrue keeps the subscription ACTIVE until currentPeriodEnd and then ends it with no further charge. Defaults to false: end it immediately.
Request
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}'
200 OK
{
  "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.

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

EventSent when
subscription.activatedThe first payment settled. The subscription exists and is ACTIVE.
invoice.paidA cycle settled, the first one included.
invoice.payment_failedA charge attempt missed, with attempt_count and next_retry_at.
subscription.past_dueThe subscription entered its retry window. Fired once per window.
subscription.canceledIt 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.