Create a subscription
This tutorial walks through creating a subscription end-to-end, including the case that surprises most integrators: the first payment on a new (inactive) payment instrument, where the response returns status: "pending" and the customer must complete the first charge on the hosted payment form.
Prerequisites
You need three things in place before creating a subscription:
- A customer with an attached payment instrument — see Create customer with payment instrument.
- A product — see Product CRUD.
- A plan — see Plan CRUD.
Have the customerId, paymentInstrumentId, and planId to hand before continuing.
The two endpoints
There are two distinct endpoints, with different semantics:
| Endpoint | Use for |
|---|---|
POST /subscriptions | Recurring subscriptions (the plan carries a recurringInterval). |
POST /subscriptions/one-time-orders | One-time orders (the plan has no recurringInterval). |
Using the recurring endpoint with a one-time plan, or vice-versa, is not supported. See One-time order for the comparison.
First-subscription flow with a new payment instrument
When the customer's payment instrument is new — attached from a fresh tokenization step and not yet charged — it starts as inactive. Payments AI does not auto-charge inactive instruments. Instead, the create response carries the URL of the hosted payment form, and your frontend redirects the customer there to complete the first payment.
For the why and the underlying state machine, see Payment instrument lifecycle and Subscription statuses.
Step 1 — Create the subscription
curl -i -X POST \
'https://staging-api.payments.ai/v1/public-api/organizations/${ORGANIZATION_ID}/subscriptions' \
-H 'Content-Type: application/json' \
-H 'Authorization: ApiKey ${API_KEY}' \
-d '{
"customerId": "cus_xxx",
"paymentInstrumentId": "inst_xxx",
"items": [{ "plan": { "id": "plan_xxx" } }],
"isTrialOnly": false
}'
Notes on the request:
paymentInstrumentIdis a flat top-level string, not nested inpaymentInstruction.itemsis[{ plan: { id } }], not[planId].isTrialOnlyis required, even whenfalse— omitting it returns a 400.
See Required fields for the full reference.
Step 2 — Read the response
For a new (inactive) instrument, the API returns:
{
"data": {
"id": "ord_xxx",
"status": "pending",
"recentInvoicePaymentFormUrl": "https://sandbox-portal.secure-payments.app/...",
"recentInvoiceId": "in_xxx"
}
}
status: "pending" plus a non-null recentInvoicePaymentFormUrl signals that the customer must be redirected.
Note: The API returns
recentInvoicePaymentFormUrlandrecentInvoiceIdin the create response when the payment instrument is inactive. These fields are required by integrators: your backend must readrecentInvoicePaymentFormUrl(and persistrecentInvoiceIdto correlate the invoice) and surface a top-levelredirectUrlfor the frontend to complete the hosted payment form and activate the subscription.
For a subscription created against an already-active instrument, status is active and there is no recentInvoicePaymentFormUrl — no redirect needed.
Step 3 — Surface the redirect URL to your frontend
The standard pattern: the backend reads recentInvoicePaymentFormUrl and adds it to the response as a top-level redirectUrl. The frontend has one place to look for the redirect target.
const sub = data?.data ?? data ?? {};
const invoicePaymentUrl = sub.recentInvoicePaymentFormUrl ?? null;
if (invoicePaymentUrl && String(sub.status ?? '').toLowerCase() === 'pending') {
return { ...data, redirectUrl: invoicePaymentUrl };
}
return data;
Step 4 — Frontend: accept pending as a redirect status
Many existing implementations only check the 3DS transaction statuses (offsite, processing, waiting) when deciding whether to redirect. Subscriptions use pending — without it in the accepted set, the redirect is skipped and the subscription appears stuck.
// Before — subscription redirect does not fire:
return (url && (status === 'offsite' || status === 'waiting'))
? url
: null;
// After — subscription redirect fires:
return (url && (status === 'offsite' || status === 'waiting' || status === 'pending'))
? url
: null;
Step 5 — Redirect the customer
window.location.href = redirectUrl;
The customer lands on the hosted payment form at secure-payments.app. Payments AI:
- Collects the card details (the data never reaches your server).
- Submits the payment to the gateway.
- Runs 3DS internally if the issuer requests a challenge.
- Activates the payment instrument on success (
inactive → active). - Redirects the customer back to your
redirectUrlwith{id}and{result}substituted.
See Hosted payment form for what the form does, and the 3DS2 guide for the protocol detail.
Step 6 — Confirm on return
When the customer returns to your site, confirm the subscription state by polling the subscription by ID (or by handling the relevant webhook). Do not rely on the URL {result} alone — the customer may close the tab before the redirect completes.
Where the redirectUrl comes from
Payments AI API response
├── data
│ ├── id: "ord_xxx"