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. PaymentsAI 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/:organizationId/subscriptions' \
-H 'Content-Type: application/json' \
-H 'Authorization: ApiKey <keyValue>' \
-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.
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 === 'processing' || status === 'waiting'))
? url
: null;
// After — subscription redirect fires:
return (url && (status === 'offsite' || status === 'processing' || 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. PaymentsAI:
- 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
PaymentsAI API response
├── data
│ ├── id: "ord_xxx"
│ ├── status: "pending"
│ ├── recentInvoicePaymentFormUrl: "https://sandbox-portal.secure-payments.app/..."
│ └── recentInvoiceId: "in_xxx"
Backend processes and adds a top-level redirectUrl:
{
"data": { "id": "ord_xxx", "status": "pending", ... },
"redirectUrl": "https://sandbox-portal.secure-payments.app/..." ← added on our side
}
Frontend extract3dsRedirectUrl() reads:
url = response.redirectUrl ← from here
status = response.data.status ← "pending", now in the accepted set
→ returns URL → redirect
What does not work (and why)
These approaches look like they should work but don't. They are the most common causes of "the subscription is stuck in pending":
| Attempted approach | Outcome |
|---|---|
POST /transactions with the subscription's invoiceId | The API accepts the request (201) but the invoiceId is ignored — the invoice stays unpaid and the subscription stays in pending. |
GET /customer-invoices/{id} followed by POST /transactions | A transaction is created but is not linked to the subscription's invoice. The subscription stays in pending. |
POST /customer-invoices/{id}/pay | Returns 404 — this endpoint does not exist. |
| Waiting for PaymentsAI to auto-charge the new instrument | Never happens. PaymentsAI does not auto-charge inactive instruments. |
Checking only status === 'waiting' on the frontend | Misses subscriptions in pending. The redirect is skipped. |
The supported path is the one above. See Subscription troubleshooting for the full list.
Subscription on an already-active instrument
If the customer is paying with an instrument that is already active (a previously used card), the create response returns status: "active" directly — no recentInvoicePaymentFormUrl, no redirect. Renewals auto-charge on schedule without any further customer interaction. The same code path as Step 3 above handles this correctly because the if (invoicePaymentUrl && status === 'pending') check simply falls through.
Create a one-time order
A one-time order uses a different endpoint and a plan without a recurringInterval:
curl -i -X POST \
'https://staging-api.payments.ai/v1/public-api/organizations/:organizationId/subscriptions/one-time-orders' \
-H 'Content-Type: application/json' \
-H 'Authorization: ApiKey <keyValue>' \
-d '{
"customerId": "cus_xxx",
"items": [{ "plan": { "id": "plan_one_time" } }]
}'
If the payment instrument is new (inactive), the response carries the same recentInvoicePaymentFormUrl pattern as a subscription — the customer completes the payment on the hosted form. See One-time order.
Create a subscription with a flexible plan
The plan can be defined inline on the items, instead of referencing an existing plan by ID:
curl -i -X POST \
'https://staging-api.payments.ai/v1/public-api/organizations/:organizationId/subscriptions' \
-H 'Content-Type: application/json' \
-H 'Authorization: ApiKey <keyValue>' \
-d '{
"autopay": true,
"customerId": "cus_xxx",
"isTrialOnly": false,
"trial": {
"enabled": true,
"endTime": "2024-08-26T15:30:00Z"
},
"items": [
{
"quantity": 1,
"plan": {
"id": "plan_flexible",
"name": "Custom 5-dollar plan",
"productId": "prod_xxx",
"currency": "USD",
"pricing": { "formula": "fixed-fee", "price": 5 },
"recurringInterval": {
"unit": "day",
"limit": 1,
"length": 1,
"billingTiming": "prepaid"
},
"trial": {
"price": 0,
"period": { "unit": "day", "length": 1 }
}
}
}
]
}'
Field names: request vs. response. Two fields use different forms in the request body than in the returned object:
- The request takes
autopay(lowercase); the response returns it asisAutoPay.- The request takes
recurringInterval.unitandtrial.period.unitin singular form ("day","week","month","year"); response payloads exposebillingCycle.unitin plural form ("days","weeks","months","years").The request body also accepts
recurringInterval.limit, which controls how many billing cycles the subscription runs before stopping.