Recurring payments
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 server1. 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.
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). |
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:
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:
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.
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 and the API reference. The rules that matter for the money:
amountmust equal the plan's amount on a fixed plan (1999↔"19.99"). On a variable plan it may be anything above zero and up tomaximumAmount. A mismatch is refused when the customer pays, with a clear message in the widget.billingAnchoris 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).
subscriptionExternalReferenceis your id for the agreement. Store it with the customer: it is the key for every call below.
Activation
success carries the agreement reference:
{ "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:
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:
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 …>"
}'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:
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" }'amountis an exact non-negative decimal string in the plan's currency, at most 18 decimal places, never abovemaximumAmount."amount": "0"closes the period without a charge: the cycle is recorded asskipped, the schedule advances one interval, you receivesubscription.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 — otherwise409with the reason. A variable cycle is refused on a fixed plan. - The
idempotencyKeyis 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 is409, and a different key for a period that already has a cycle is409. 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.
{
"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) | 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 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
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:
{ "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:
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.
6. Webhooks
Agreements report through the same webhook 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:
{
"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. 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
subscriptionExternalReferencewith your customer, and for variable plans theidempotencyKeyof every cycle you submit. - Grant access on
status: "active"(read back), extend it onsubscription.renewal_paid, restrict it onsubscription.renewal_past_due. - Never mark the first payment as a subscription from the browser's
successalone — confirm the intent and read the agreement, as with any payment.