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 completes the first charge on the hosted payment form.

There are two ways to take a subscription. The hosted form flow below is driven from your backend and redirects the customer to Payments AI to pay. The embedded flow runs in your own page with no redirect. Both are covered here; see Embedded vs. hosted form for the comparison.

Prerequisites

Option 1 is driven from your backend, so you need three things in place before you call the API:

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

Option 2 needs none of this: the library creates the customer and tokenizes the card itself. It has its own, shorter prerequisites, listed in that section.

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.

Option 1 — Hosted form: first subscription 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:

  • 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.

Note: The API returns recentInvoicePaymentFormUrl and recentInvoiceId in the create response when the payment instrument is inactive. These fields are required by integrators: your backend must read recentInvoicePaymentFormUrl (and persist recentInvoiceId to correlate the invoice) and surface a top-level redirectUrl for 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 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

Payments AI 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.
Passing a tokenization token when creating the subscriptionSilently ignored, in either shape (a flat token or a nested paymentInstruction). No error is returned, no payment instrument is created, and the subscription behaves exactly as if no card had been supplied. Unlike POST /transactions, this endpoint charges only a paymentInstrumentId, so attach the token first. See Attach a token to a customer.
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 Payments AI to auto-charge the new instrumentNever happens. Payments AI 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/${ORGANIZATION_ID}/subscriptions/one-time-orders' \
-H 'Content-Type: application/json' \
-H 'Authorization: ApiKey ${API_KEY}' \
-d '{
"customerId": "cus_xxx"
}'

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.

The embedded form covers this case too, without a separate endpoint: pass a plan that has no recurringInterval in items and the same mount() call produces a one-time order instead of a subscription. See Option 2.

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/${ORGANIZATION_ID}/subscriptions' \
-H 'Content-Type: application/json' \
-H 'Authorization: ApiKey ${API_KEY}' \
-d '{

"customerId": "cus_xxx",
"isTrialOnly": false,
"trial": {
"enabled": true,
"endAt": "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": "next",
"length": 1,
"billingTiming": "prepaid"
},
"trial": {
"price": 0,
"period": { "unit": "day", "length": 1 }
}
}
}
]
}'

Field names: request vs. response.

  • The API does not accept an autopay field on the subscription create request; responses may include an isAutoPay property in certain views. Use paymentInstruction / paymentInstrumentId as documented for transaction/subscription requests.

  • The request takes recurringInterval.unit as a billing anchor value: "next", "first-in-month", or "last-in-month". Response payloads expose billingCycle.unit in plural form ("days", "weeks", "months", "years").

Option 2 — Embedded payment form

This is a second way to take a subscription, alongside the hosted-form flow above. Here the payment form is embedded directly in your page using the payment library, installed as an npm package and bundled into your own application. The customer completes the purchase without leaving your site, and there is no redirect.

Because the library compiles into the JavaScript you already serve, there is no external <script> tag pointing at a third-party host.

One mount() call covers both cases. A recurring plan produces a subscription; a non-recurring plan produces a one-time order. There is no separate endpoint or configuration to switch between them, unlike the two REST endpoints described above.

Which flow you use is your choice, in your own checkout code. Nothing is toggled on the platform side.

Prerequisites

  • A website ID, an organization ID, and a publishable API key.
  • A product.
  • A recurring plan, meaning one that defines a recurringInterval. A plan without one produces a one-time order instead; the library follows the plan type. See Recurring interval.
  • A payment gateway configured on your account. In sandbox, the TestProcessor gateway is pre-configured.
  • If your site uses a Content Security Policy, allow the payment frame origins in frame-src and the API host in connect-src before embedding.

Step 1 — Install the library

Using yarn:

yarn add @rebilly/instruments

Or npm:

npm install @rebilly/instruments

Step 2 — Add the mount points

The library renders into two containers: the payment form and the order summary. Add these elements to your checkout page. The default selectors are .rebilly-instruments for the form and .rebilly-instruments-summary for the summary:

<div class="rebilly-instruments-summary"></div>
<div class="rebilly-instruments"></div>

Using your own class names. If you would rather keep the markup under your own naming, use any selectors you like and pass them to mount() as form and summary in Step 3. The two must match:

<div class="checkout-summary"></div>
<div class="checkout-form"></div>
form: '.checkout-form',
summary: '.checkout-summary',

Omit form and summary entirely to use the defaults above.

Step 3 — Configure and mount

Import the library into your application code and call mount(). It returns a Promise and takes a single configuration object. Pass your credentials and the plan to sell in items. This example uses the default mount points, so it passes no selectors:

import RebillyInstruments from '@rebilly/instruments';

RebillyInstruments.mount({
organizationId: 'org_xxx',
publishableKey: 'pk_xxx',
websiteId: 'web_xxx',
apiMode: 'sandbox', // 'live' in production
items: [
{ planId: 'plan_xxx', quantity: 1 } // a recurring plan creates a subscription
],
});

Notes on the configuration:

  • items drives what is sold. A recurring planId creates a subscription; a non-recurring planId creates a one-time order. Same call, the plan type decides.
  • Optional per-item properties: thumbnail, and a quantity range object ({ default, minimum, maximum, multipleOf }) to let the customer adjust quantity.
  • Optional top-level properties: addons, bumpOffer, css, theme, i18n, locale, and a features object (for example hideConfirmation, hideResult) for custom confirmation and result screens.
  • apiMode: 'sandbox' and the staging-api.payments.ai host used in Option 1 point at the same non-production environment. The API host calls it stage; publishable keys (pk_sandbox_...) and this library call it sandbox. Use 'live' in production, against api.payments.ai. See Base URLs.
  • Custom mount points: pass form and summary with your own selectors if you are not using the defaults, for example form: '.checkout-form'.
  • The imported binding is named RebillyInstruments. That name is fixed by the package and cannot be configured.

Step 4 — Listen for events

RebillyInstruments.on('instrument-ready', (instrument) => {
// Card data was captured and tokenized in the isolated form fields.
// It never touches your server.
});

RebillyInstruments.on('purchase-completed', (result) => {
const outcome = result?.transaction?.result;
if (outcome === 'approved') {
// Purchase completed and active. Show success and send the customer on.
} else {
// Declined. Surface a retry.
}
});

On the first purchase the library creates the customer, tokenizes the card, runs 3DS if the issuer requests it, and activates the resulting subscription or one-time order, all inside the embedded form.

Step 5 — Confirm on your backend

Treat purchase-completed as a UX signal, not proof. Before granting access, confirm the state server-side: poll the created record by ID, whether that is a subscription or a one-time order, or handle a lifecycle webhook. A webhook is the reliable source of truth if the customer closes the tab. Note that offsite-payment-completed, which the hosted form fires on return, does not apply here: the embedded flow has no offsite step. See Subscription webhooks for the event list.

Step 6 — Test

Serve the page from a web server, because browsers block the API over file:// due to CORS. Then complete a purchase with the no-3DS test card: number 5577 0000 5577 0004, expiry 03/2030, CVV 737. That card skips authentication, so it exercises the embed itself rather than the challenge flow. For cards that force a challenge or a frictionless outcome, see Test cards.

Embedded vs. hosted form

Both options are PCI-safe: card data is entered in isolated fields and never reaches your server.

Hosted form (option 1)Embedded (option 2)
Where the customer paysRedirected to the hosted payment formOn your own page
How it startsBackend POST /subscriptions, then redirectClient-side mount() with the plan
Redirect away from your siteYesNo
Card data reaches your serverNoNo

Pick per your checkout design. Nothing is toggled on the platform side; your code decides which one runs.