# Recurring payments

URL: https://doc.securepayapi.com/recurring-payments
Last updated: 2026-11-05

> Subscriptions end to end — plans (fixed or variable price), the first charge through the widget, activation, renewals, variable-amount billing, trials, reading an agreement, cancelling, and the webhooks that report it all.

A **subscription** is an agreement to charge a customer's card again and
again. securepayAPI keeps the card, the schedule, the retries and the
lifecycle; your server never sees card data and never runs a billing job for
a fixed-price plan.

Three objects, all under your secret key (`Authorization: Bearer sp_secret_…`):

| Object | What it is | Where |
| --- | --- | --- |
| **Plan** | The product: price (or a price cap), currency, interval, consent terms | `/api/v1/subscription-plans` |
| **Payment Intent** with a `subscription` block | The customer's first charge **and** consent, collected in the widget | `/api/v1/payment-intents` |
| **Agreement** (the subscription itself) | What the platform bills from then on; read, bill, pause, cancel | `/api/v1/subscriptions` |

Every call takes an optional `mode` (`"live"`, the default, or `"sandbox"`),
exactly like a Payment Intent. Plans and agreements live in one cabinet or the
other; a sandbox plan cannot be named by a live intent.

```
your server ──POST /api/v1/subscription-plans──▶ plan "monthly-gold" (once)
     │
     ├─ POST /api/v1/payment-intents { amount, subscription: { planExternalReference: "monthly-gold", … } }
     │
   browser ──widget: card + consent checkbox──▶ success { subscription: { status: "pending_activation" } }
     │
your server ──GET /api/v1/subscriptions/membership-42──▶ status "active" → grant access
     │
   platform ──bills every period──▶ webhooks subscription.renewal_paid / … ──▶ your server
```

## 1. Create a plan

A plan is immutable once created (you add **versions** to change it, see
below). Create it once, then name it from every intent that sells it.

```bash
curl https://checkout.securepayapi.com/api/v1/subscription-plans \
  -H "Authorization: Bearer sp_secret_…" \
  -H "Content-Type: application/json" \
  -d '{
    "externalReference": "monthly-gold",
    "name": "Gold",
    "amountMode": "fixed",
    "amount": "19.99",
    "currency": "USD",
    "intervalUnit": "month",
    "intervalCount": 1,
    "termsVersion": "2026-10-01",
    "termsHash": "5d48345db32747eed3f5666e43c946652760795f35d09d81c4e0f6f29c353ed3"
  }'
```

| Field | Required | Contract |
| --- | --- | --- |
| `externalReference` | yes | Your id for the plan, 1–255 chars. |
| `name` | yes | Display name, 1–255 chars. |
| `amountMode` | yes | `"fixed"` or `"variable"` (see [Fixed and variable plans](#fixed-and-variable-plans)). |
| `amount` | fixed plans | Exact positive decimal string, e.g. `"19.99"`. Not allowed on a variable plan. |
| `maximumAmount` | variable plans | The most a single period may charge, decimal string. Not allowed on a fixed plan. |
| `currency` | yes | `"USD"`. |
| `intervalUnit`, `intervalCount` | yes | `"day"`, `"week"`, `"month"` or `"year"`, and how many of them per period (`1` month, `3` months, …), 1–365. |
| `termsVersion` | yes | Your label for the consent terms, 1–64 chars. |
| `termsHash` | yes | Lowercase SHA-256 hex (64 chars) of the exact consent text you will show. |
| `idempotencyKey` | no | A UUID. Generated for you when absent and echoed in the answer. |
| `mode` | no | `"live"` (default) or `"sandbox"`. |

Money is always a **decimal string** here (`"19.99"`), not minor units — only
the Payment Intent uses cents (`1999`). The intent that sells this plan must
carry the same amount: `1999` for `"19.99"`.

Sending the same `externalReference` again is accepted only when every fact is
unchanged (you get the existing plan back, with `created: false`); a changed
fact is refused. To change a plan, add a version:

```bash
curl https://checkout.securepayapi.com/api/v1/subscription-plans/versions \
  -H "Authorization: Bearer sp_secret_…" \
  -H "Content-Type: application/json" \
  -d '{ "externalReference": "monthly-gold", "expectedVersion": 1,
        "amountMode": "fixed", "amount": "24.99",
        "currency": "USD", "intervalUnit": "month", "intervalCount": 1,
        "termsVersion": "2027-01-01", "termsHash": "<sha256 of the new terms text>" }'
```

`expectedVersion` is **required**: the plan's current version number as you
last read it (`currentVersion.version` from `GET …?externalReference=`, `1`
for a plan that was never versioned). The version is appended only if the plan
is still at that number — if someone added a version in between, the call is
refused with `409` and you re-read the plan first. Existing agreements keep
the version they consented to; new intents get the current one. `POST /api/v1/subscription-plans/archive` stops new agreements on
a plan (existing ones keep billing); `…/reactivate` allows them again. Both
take `{ externalReference, idempotencyKey?, mode? }`.

Read plans back with `GET /api/v1/subscription-plans?externalReference=…` (one
plan, with all its versions) or `?status=active&limit=25&cursor=…` (a page:
`{ items, nextCursor }`).

### The consent text and its hash

The customer must read and tick the exact terms you hashed. Write the text
once, hash it, and keep both:

```js
const { createHash } = require("node:crypto");
const termsText = "I authorize securepayAPI to charge my card USD 19.99 every month " +
                  "starting on the date shown, until I cancel.";
const termsHash = createHash("sha256").update(termsText, "utf8").digest("hex");
```

The plan stores `termsVersion` + `termsHash`; the intent sends the same two
plus `termsText`, and the widget shows `termsText` to the customer. If the
text does not hash to the plan's current `termsHash`, the intent is refused —
so what the customer sees is always what they agreed to.

## 2. The first charge: a Payment Intent with `subscription`

Sell the plan by creating an intent that names it. The customer pays the first
period in the widget and ticks the consent box; nothing else changes in your
integration.

```bash
curl https://checkout.securepayapi.com/api/v1/payment-intents \
  -H "Authorization: Bearer sp_secret_…" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 1999,
    "currency": "USD",
    "reference": "order-9001",
    "subscription": {
      "planExternalReference": "monthly-gold",
      "shopperExternalReference": "cust_88213",
      "subscriptionExternalReference": "membership-42",
      "termsVersion": "2026-10-01",
      "termsText": "I authorize securepayAPI to charge my card USD 19.99 every month starting on the date shown, until I cancel.",
      "termsHash": "5d48345db32747eed3f5666e43c946652760795f35d09d81c4e0f6f29c353ed3",
      "billingAnchor": "2026-12-03T12:00:00-05:00",
      "billingTimezone": "America/New_York"
    }
  }'
```

Every field of the block is specified in
[Payment Intents → Recurring payments](https://doc.securepayapi.com/payment-intents#recurring-payments-subscriptions)
and the [API reference](https://doc.securepayapi.com/api-reference#the-subscription-object). The rules that
matter for the money:

- **`amount` must equal the plan's amount** on a fixed plan (`1999` ↔ `"19.99"`).
  On a variable plan it may be anything **above zero and up to**
  `maximumAmount`. A mismatch is refused when the customer pays, with a clear
  message in the widget.
- **`billingAnchor` is when recurring billing starts** — the first *renewal*.
  The customer pays today; the next charge happens at the anchor, and every
  interval after it. The anchor must be at least an hour ahead when you create
  the intent and still in the future when the customer pays (an intent lives
  7 days — give it room).
- **Cards only.** Apple Pay and Google Pay are not offered on a subscription
  intent; the **email is required** (renewal notices and receipts go there).
- `subscriptionExternalReference` is **your** id for the agreement. Store it
  with the customer: it is the key for every call below.

### Activation

`success` carries the agreement reference:

```json
{ "intentId": "pi_…", "status": "succeeded", "reference": "LU…",
  "subscription": { "status": "pending_activation", "externalReference": "membership-42" } }
```

The first charge is captured, but the agreement goes live only once the
platform has confirmed the first payment's authentication on its side —
normally seconds later. **Do not grant access on `pending_activation` alone.**
Read the agreement:

```bash
curl "https://checkout.securepayapi.com/api/v1/subscriptions/membership-42" \
  -H "Authorization: Bearer sp_secret_…"
```

and grant access when `status` is `"active"`. There is deliberately **no
`subscription.activated` webhook** — poll this read a few times after
`success`, or wait for the first `subscription.renewal_*` event.

An agreement activates only when the first payment was authenticated by the
issuer with 3-D Secure (liability shifted). If the issuer did not authenticate
the card, the charge was still captured but the agreement stays
`pending_setup`. If it is still `pending_setup` after a few minutes, treat the
setup as **not completed**: the customer paid one period and nothing will be
billed again — fulfil that period as a one-off or refund it, and ask for
another card.

## Fixed and variable plans

| | Fixed (`amountMode: "fixed"`) | Variable (`amountMode: "variable"`) |
| --- | --- | --- |
| Price | `amount`, the same every period | A cap, `maximumAmount`; the real amount is yours per period |
| First charge | Must equal `amount` | Any amount `> 0` and `≤ maximumAmount` |
| Renewals | **Billed by the platform** at `nextBillingAt`, nothing to do | **You submit each period's amount** with `POST /api/v1/subscriptions/cycles`; nothing is charged until you do |
| Typical use | Memberships, SaaS seats | Usage-based billing, metered plans, promo pricing, free periods |

### A variable plan and its first charge

The plan names the cap, not a price; the intent names the first amount:

```bash
curl https://checkout.securepayapi.com/api/v1/subscription-plans \
  -H "Authorization: Bearer sp_secret_…" \
  -H "Content-Type: application/json" \
  -d '{
    "externalReference": "usage-pro",
    "name": "Pro (metered)",
    "amountMode": "variable",
    "maximumAmount": "99.00",
    "currency": "USD",
    "intervalUnit": "month",
    "intervalCount": 1,
    "termsVersion": "2026-10-01",
    "termsHash": "<sha256 of: I authorize securepayAPI to charge my card up to USD 99.00 every month …>"
  }'
```

```bash
curl https://checkout.securepayapi.com/api/v1/payment-intents \
  -H "Authorization: Bearer sp_secret_…" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 100,
    "currency": "USD",
    "reference": "order-9002",
    "subscription": {
      "planExternalReference": "usage-pro",
      "shopperExternalReference": "cust_88213",
      "subscriptionExternalReference": "usage-42",
      "termsVersion": "2026-10-01",
      "termsText": "I authorize securepayAPI to charge my card up to USD 99.00 every month for my usage, starting on the date shown, until I cancel.",
      "termsHash": "<sha256 of termsText>",
      "billingAnchor": "2026-12-01T00:00:00-05:00",
      "billingTimezone": "America/New_York"
    }
  }'
```

`amount: 100` ($1.00) is accepted because it is above zero and below
`"99.00"`. From `billingAnchor` on, every period is billed only by your
`POST /api/v1/subscriptions/cycles` call below (`"99.00"`, `"12.34"`,
`"0"` — whatever that period is worth, never above the cap).

### Billing a variable period

When the agreement is **due** (`nextBillingAt` has passed), submit the amount
for that period. The platform charges the stored card and moves the schedule
one interval forward:

```bash
curl https://checkout.securepayapi.com/api/v1/subscriptions/cycles \
  -H "Authorization: Bearer sp_secret_…" \
  -H "Content-Type: application/json" \
  -d '{ "externalReference": "membership-42", "amount": "5.99",
        "idempotencyKey": "6f1c2a0e-5b1d-4c0a-9d8e-3b2a1c0d9e8f" }'
```

- `amount` is an exact **non-negative** decimal string in the plan's currency,
  at most 18 decimal places, never above `maximumAmount`.
- `"amount": "0"` closes the period **without a charge**: the cycle is recorded
  as `skipped`, the schedule advances one interval, you receive
  `subscription.renewal_skipped`. No card is touched.
- **Not sending the request does not mean zero.** The period stays due and the
  schedule does not move until you submit something.
- The agreement must be `active`, due, not cancelled, with an active card
  credential — otherwise `409` with the reason. A variable cycle is refused on a
  fixed plan.
- The `idempotencyKey` is **required** here and owns the submitted facts
  **permanently**: the same key with the same amount returns the original
  cycle, the same key with a different amount is `409`, and a different key
  for a period that already has a cycle is `409`. Generate the key when you
  decide the amount, store it, and reuse it on every retry — a retry under a
  new key would be refused, and you could not tell whether the first call
  billed.

A positive amount enters the same path as a fixed renewal: the same retries,
the same `subscription.renewal_paid` / `renewal_retry_scheduled` /
`renewal_past_due` webhooks.

### Trials, introductory prices, free periods

The platform never stores a card without a charge — the first payment is what
creates the agreement, and it must be above zero. Within that rule:

| You want | Do this |
| --- | --- |
| **First period at the full price, the next charge later** ("30 days for the price of one", annual plans billed in advance) | Fixed plan; set `billingAnchor` to when the second charge should happen. |
| **Introductory price, e.g. $1 for the first week, then $5.99 a month** | Variable plan with `maximumAmount: "5.99"`, `intervalUnit: "month"`. Intent `amount: 100`, `billingAnchor` = now + 7 days. From then on submit `"5.99"` each month with `/cycles`. |
| **A free month in the middle** (pause billing without pausing the agreement) | Variable plan: submit `"amount": "0"` for that period. |
| **Full price, but one period on the house** | Fixed plan: `pause` before the period, `resume` after — or refund that renewal. |
| **$0 trial / card check only** | Not available: there is no zero-amount setup. Charge a nominal first amount instead. |

## 3. Reading an agreement

```
GET /api/v1/subscriptions/{externalReference}?mode=live
GET /api/v1/subscriptions?externalReference=…&mode=live       (same thing)
GET /api/v1/subscriptions?status=past_due&limit=25&cursor=…   (a page: { items, nextCursor })
```

One agreement comes with its timeline: the last cycles, each with its
attempts, and the lifecycle events.

```json
{
  "externalReference": "membership-42",
  "environment": "production",
  "status": "active",
  "cancelAtPeriodEnd": false,
  "billingAnchor": "2026-12-03T17:00:00.000Z",
  "billingTimezone": "America/New_York",
  "nextBillingAt": "2027-01-03T17:00:00.000Z",
  "startedAt": "2026-11-03T14:12:09.000Z",
  "pausedAt": null, "cancelledAt": null, "endedAt": null,
  "shopperExternalReference": "cust_88213",
  "customerContact": { "configured": true, "updatedAt": "2026-11-03T14:12:09.000Z" },
  "plan": { "externalReference": "monthly-gold", "name": "Gold", "version": 1,
            "amountMode": "fixed", "amount": "19.99", "maximumAmount": null,
            "currency": "USD", "intervalUnit": "month", "intervalCount": 1,
            "termsVersion": "2026-10-01", "termsHash": "5d48…3ed3" },
  "paymentMethod": { "status": "active", "bin": "51716665", "lastFour": "5100",
                     "country": "US", "activatedAt": "…", "recoveryInProgress": false },
  "recovery": { "eligible": false, "cycleStatus": "paid", "periodStart": "…", "periodEnd": "…",
                "amount": "19.99", "currency": "USD", "attemptNumber": 1,
                "attemptStatus": "authorised", "refusalCode": null, "retryAt": null },
  "cycles": [ { "periodStart": "…", "periodEnd": "…", "billingAt": "…", "amount": "19.99",
                "currency": "USD", "status": "paid", "paid": true, "attempts": [ … ] } ],
  "events": [ { "type": "subscription.renewal_paid", "occurredAt": "…", "data": { … } } ]
}
```

### Statuses

| `status` | Meaning | What you do |
| --- | --- | --- |
| `pending_setup` | The first charge is captured; activation not yet confirmed (or never will be, see [Activation](#activation)) | Wait a few minutes; then treat as not set up |
| `active` | Billing on schedule | Grant access |
| `past_due` | A renewal was refused and is being retried, or retries ran out | Keep or restrict access per your policy; offer a [card update](https://doc.securepayapi.com/payment-intents#recurring-payments-subscriptions) when `recovery.eligible` is `true` |
| `paused` | You paused it: no claims, no retries | — |
| `cancelled` | Ended — immediately, or at the period end that has now passed. `cancelAtPeriodEnd: true` with status `active` means a cancellation is scheduled | Revoke access when `endedAt` passes |
| `completed` | Ran to its natural end | — |

`paymentMethod.status` is the stored card credential: `pending` (setup not
confirmed), `active`, `revoked` (cancelled, or replaced), `expired`.

Cycle statuses: `scheduled`, `processing`, `paid`, `retryable` (refused, a
retry is scheduled), `failed` (retries exhausted), `outcome_unknown` (the
provider did not answer; the platform reconciles it), `skipped` (a zero
variable period).

### Renewals and retries

A fixed renewal is attempted at `nextBillingAt`. A refusal is retried up to
**4 attempts over 30 days** — by default 1, 3 and 7 days apart, adjusted when
the issuer's advice code asks for a different delay or says not to retry at
all. Each step is reported: `subscription.renewal_retry_scheduled`
(`nextRetryAt`), then `subscription.renewal_past_due` once retries are
exhausted. The agreement is `past_due` meanwhile; it returns to `active` when a
retry succeeds (`subscription.renewal_recovered`) or when the customer gives a
new card.

The customer receives email notices from the platform at the agreement's
address: activation, upcoming renewal, a retry, past-due, a scheduled or final
cancellation, and a receipt for every paid renewal. Each non-final notice
carries a link where the customer can cancel online — at the period end or at
once — through the same lifecycle as your API call, so you receive the same
`subscription.cancelled` webhook either way. Change that address with
`POST /api/v1/subscriptions/contact` `{ externalReference, customerEmail }`.

## 4. Cancel, pause, resume

```bash
curl https://checkout.securepayapi.com/api/v1/subscriptions/cancel \
  -H "Authorization: Bearer sp_secret_…" \
  -H "Content-Type: application/json" \
  -d '{ "externalReference": "membership-42", "atPeriodEnd": true }'

curl https://checkout.securepayapi.com/api/v1/subscriptions/pause \
  -H "Authorization: Bearer sp_secret_…" -H "Content-Type: application/json" \
  -d '{ "externalReference": "membership-42", "idempotencyKey": "0b6d1c62-4f0a-4a5e-9c1b-7a2d3e4f5061" }'

curl https://checkout.securepayapi.com/api/v1/subscriptions/resume \
  -H "Authorization: Bearer sp_secret_…" -H "Content-Type: application/json" \
  -d '{ "externalReference": "membership-42", "idempotencyKey": "7c1e9a40-2b3d-4e5f-8a9b-0c1d2e3f4a5b" }'
```

Answer, for all three:

```json
{ "externalReference": "membership-42", "status": "active", "cancelAtPeriodEnd": true,
  "effectiveAt": "2027-01-03T17:00:00.000Z", "created": true,
  "idempotencyKey": "3f6a2b1c-9d8e-4f70-a1b2-c3d4e5f60718" }
```

| Call | Body | Effect |
| --- | --- | --- |
| `POST /api/v1/subscriptions/cancel` | `externalReference`, `atPeriodEnd` (default `true`), `idempotencyKey?`, `mode?` | `atPeriodEnd: true`: billing stops, access runs to the end of the paid period (`cancelAtPeriodEnd: true`, then `cancelled` with `effectiveAt`). `false`: ends now. If a renewal is definitively unpaid, a period-end cancellation ends at that period's boundary without more retries. |
| `POST /api/v1/subscriptions/pause` | `externalReference`, `idempotencyKey?`, `mode?` | No new charges, no retries. The unpaid cycle, if any, is kept. |
| `POST /api/v1/subscriptions/resume` | same | Billing resumes; the stored card credential is re-checked first. |

All three answer `{ externalReference, status, cancelAtPeriodEnd, effectiveAt, created, idempotencyKey }`
and emit `subscription.cancellation_scheduled` / `subscription.cancelled` /
`subscription.paused` / `subscription.resumed`. They are refused with `409`
while a cycle is **processing**, has an **unknown outcome**, or a **card
replacement is in progress** — the answer must never promise that an
in-flight charge will not settle. Retry a little later.

`idempotencyKey` is optional: send your own UUID to make a retry safe (the
same key repeats the same transition), or omit it and get a fresh one echoed
back.

## 5. Replacing the card

When a renewal keeps failing, `recovery.eligible` becomes `true` on the
agreement and `recovery.amount` says what is owed. Create a Payment Intent for
exactly that amount:

```bash
curl https://checkout.securepayapi.com/api/v1/payment-intents \
  -H "Authorization: Bearer sp_secret_…" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 1999,
    "currency": "USD",
    "reference": "membership-42-recovery-2027-02",
    "subscription": {
      "operation": "recoverPaymentMethod",
      "subscriptionExternalReference": "membership-42",
      "termsVersion": "2026-10-01",
      "termsText": "I authorize securepayAPI to charge my card USD 19.99 every month starting on the date shown, until I cancel.",
      "termsHash": "5d48345db32747eed3f5666e43c946652760795f35d09d81c4e0f6f29c353ed3"
    }
  }'
```

No plan, shopper or anchor fields here — the agreement already has them.
Send the customer to the widget: it collects the new card under the same
rules, pays the unpaid period, and the agreement returns to `active`
(`subscription.payment_method_recovered`, `subscription.renewal_recovered`).
Details in [Payment Intents](https://doc.securepayapi.com/payment-intents#recurring-payments-subscriptions).

## 6. Webhooks

Agreements report through the same [webhook](https://doc.securepayapi.com/webhooks#subscription-events)
endpoint, signed with the same secret. The events you will act on most:

| Event | When |
| --- | --- |
| `subscription.renewal_paid` | A period was charged — extend access |
| `subscription.renewal_retry_scheduled` | A charge was refused, a retry is planned (`nextRetryAt`) |
| `subscription.renewal_past_due` | Retries exhausted — restrict access, offer a card update |
| `subscription.renewal_recovered` | A retry or a new card paid the overdue period |
| `subscription.renewal_skipped` | You submitted a zero variable period |
| `subscription.cancellation_scheduled`, `subscription.cancelled` | By your API call or by the customer online (`effectiveAt`) |

Each carries `id`, `type`, `subscriptionExternalReference`, `environment`,
`status` and `occurredAt`. Renewal events add `attemptNumber`, `outcome`,
`paymentId`, `refusalCode`, `nextRetryAt` and `retryReason`;
`renewal_skipped` adds `amount`, `currency`, `periodStart`, `periodEnd`
instead. A paid renewal looks like this:

```json
{
  "id": "subscription:0:…:membership-42:1:renewal_paid",
  "type": "subscription.renewal_paid",
  "subscriptionExternalReference": "membership-42",
  "environment": 0,
  "status": "active",
  "attemptNumber": 1,
  "outcome": "paid",
  "paymentId": "LU…",
  "refusalCode": null,
  "nextRetryAt": null,
  "retryReason": null,
  "occurredAt": "2027-01-03T17:00:04.000Z"
}
```

The amount of a paid renewal is the plan's `amount` (fixed) or what you
submitted for that period (variable); read the agreement's `cycles` for the
money and the dates. Handle every event idempotently by `id`; when in doubt,
read the agreement — it is authoritative.

## Errors

The subscription endpoints use the same envelope as the rest of the
[API](https://doc.securepayapi.com/api-reference#errors). Two types are specific to them:

| `type` | Status | `code` | When |
| --- | --- | --- | --- |
| `invalid_request` | `400` | `rejected` | The platform refused the request; `message` says why (an amount above the plan's maximum, a changed plan fact, a non-UUID key, …). Field-level codes (`externalReference_invalid`, `mode_invalid`, `idempotencyKey_invalid`, `idempotencyKey_required` on `/cycles`, `expectedVersion_required` / `expectedVersion_invalid` on `/versions`, `atPeriodEnd_invalid`, `amount_invalid`, …) are checked before the call. |
| `forbidden` | `403` | `not_enabled` | A feature not enabled on the account (physical-order renewals). |
| `not_found` | `404` | `not_found` | No plan or agreement with that reference in that cabinet. |
| `conflict` | `409` | `conflict` | The state does not allow it: a changed plan fact, a cycle already billed for that period, a transition while a charge is in flight — `message` names the reason. |
| `server` | `502` | `upstream_unavailable`, `upstream_error`, `credentials_unavailable`, `processor_unconfigured` | The platform could not be reached or answered unexpectedly. Retry; if it persists, contact support. |
| `server` | `503` | `subscriptions_disabled` | Subscriptions are not enabled on this platform yet. Not retryable — contact support. |

An account on which the requested `mode` is not enabled yet (for example
sandbox not switched on) is refused with `400` `rejected` and the message
`processor configuration is unavailable for this merchant` — contact support
to enable it.

## Before going live

- Recurring billing is enabled per cabinet by securepayAPI after the account
  is ready for it (payout destination, limits, webhook endpoint). **Build and
  test in sandbox first**; a sandbox plan and agreement behave exactly like
  live ones without moving money.
- Store `subscriptionExternalReference` with your customer, and for variable
  plans the `idempotencyKey` of every cycle you submit.
- Grant access on `status: "active"` (read back), extend it on
  `subscription.renewal_paid`, restrict it on `subscription.renewal_past_due`.
- Never mark the first payment as a subscription from the browser's `success`
  alone — [confirm the intent](https://doc.securepayapi.com/payment-intents#confirm-the-payment-on-your-server)
  and read the agreement, as with any payment.
