# 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](/developers/docs/checkout), 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](/developers/docs/webhooks).

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/sessions` with `mode: "SUBSCRIPTION"` and the recurring terms in `price_data`. You get back a hosted `url`.
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.activated` and `invoice.paid` land on your webhook endpoint. The subscription is `ACTIVE` and cycle one is paid.
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](/developers/docs/authentication). 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](/developers/docs/specs).

> [!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](/developers/docs/webhooks) objects use snake_case with Unix timestamps. Map the three separately rather than reusing one model across them.

## Create a subscription checkout

```http title="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

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

```bash title="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
    }
  }'
```

```json title="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:

```json title="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

| 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

```http title="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.

```bash title="Request"
curl https://api.payer.app/subscriptions/sub_customer_10482 \
  -H "Authorization: Bearer sk_test_..."
```

```json title="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"
}
```

| 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

```http title="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.

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

```bash title="Request"
curl "https://api.payer.app/subscriptions?status=PAST_DUE&limit=50" \
  -H "Authorization: Bearer sk_test_..."
```

## Billing history

```http title="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.

```json title="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"
  }
]
```

| 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](/developers/docs/mandates#when-a-charge-is-refused), 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_DUE` and `subscription.past_due` fires 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_failed` carrying `attempt_count` and `next_retry_at`.
4. A retry that succeeds pays the cycle, fires `invoice.paid`, and the subscription returns to `ACTIVE` on its original anchor. Nothing shifts.
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

```http title="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.

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

```bash title="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}'
```

```json title="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.

| 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](/developers/docs/webhooks#subscription-payloads) 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](/developers/docs/webhooks#subscription-payloads) page, and use **resend** in the delivery log on the Developers page to replay a delivery into them as often as you like.
