Skip to main content

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:

Have the customerId, paymentInstrumentId, and planId to hand before continuing.

The two endpoints

There are two distinct endpoints, with different semantics:

EndpointUse for
POST /subscriptionsRecurring subscriptions (the plan carries a recurringInterval).
POST /subscriptions/one-time-ordersOne-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:

  • paymentInstrumentId is a flat top-level string, not nested in paymentInstruction.
  • items is [{ plan: { id } }], not [planId].
  • isTrialOnly is required, even when false — 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 redirectUrl with {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 approachOutcome
POST /transactions with the subscription's invoiceIdThe 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 /transactionsA transaction is created but is not linked to the subscription's invoice. The subscription stays in pending.
POST /customer-invoices/{id}/payReturns 404 — this endpoint does not exist.
Waiting for PaymentsAI to auto-charge the new instrumentNever happens. PaymentsAI does not auto-charge inactive instruments.
Checking only status === 'waiting' on the frontendMisses 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 as isAutoPay.
  • The request takes recurringInterval.unit and trial.period.unit in singular form ("day", "week", "month", "year"); response payloads expose billingCycle.unit in plural form ("days", "weeks", "months", "years").

The request body also accepts recurringInterval.limit, which controls how many billing cycles the subscription runs before stopping.