Create subscriptions with Elements

Use Paypercut Elements in subscription mode to collect a card, Apple Pay, or Google Pay on your own signup page, then create the Subscription from your backend. Elements returns an opaque Payment Method ID and prepares authentication for off-session use. Your backend creates the Subscription with the Customer, recurring Price, and Payment Method; the first successful authorization establishes the method for renewal billing.

This guide requires @paypercut/checkout-js 1.4.1 or later and covers Express Checkout with a Card Element fallback. Use subscription Checkout when Paypercut should host or embed the complete signup flow.

This flow requires an immediate, non-zero first payment with payment_behavior: 'error_if_incomplete'. Trials and deferred first charges are not supported by this collection-to-subscription flow. Do not add trial_end, trial_period_days, billing_cycle_anchor, or billing_cycle_anchor_config to this example.

How the flow works

mode: 'subscription' tells Elements to prepare the collected payment details for off-session use. It does not create a Subscription, attach the Payment Method to a Customer, establish a recurring agreement, or charge the customer. Subscription creation and the first authorization happen on the backend.

You do not need to set setupFutureUsage or enable a separate subscription-save setting in the browser. Subscription mode derives off_session automatically. Your backend must still pass the returned Payment Method as default_payment_method and use the immediate first-payment behavior shown below.

Responsibilities

Actor Responsibility
Your frontend Mount Elements, collect consent, receive the wallet Payment Method or create the card Payment Method, and send its opaque ID to your backend. Settle the wallet after the backend responds.
Your backend Resolve the authenticated customer and selected plan, validate and record consent, create the Subscription, store Paypercut IDs, and process invoice webhooks.
Paypercut Host secure collection, tokenize and authenticate payment details, authorize the first subscription payment, prepare recurring reuse, and attempt future renewals.

Use a publishable key in the browser. Keep your secret API key, Price mapping, and authoritative subscription terms on your backend.

Before you begin

You need:

  • a Paypercut account with a publishable key and secret API key for the same account and test/live mode;
  • a Product and recurring Price for the plan;
  • a Paypercut Customer mapped to the signed-in user or account;
  • an HTTPS signup page in production;
  • Apple Pay domain registration if you offer Apple Pay;
  • a backend endpoint that creates subscriptions;
  • a webhook endpoint that verifies Paypercut signatures and handles invoice events;
  • clear customer consent for recurring, off-session charges.

The amount and billing interval shown on your page must match the recurring Price selected by your backend.

Wallet eligibility still depends on the customer's browser, device, and wallet configuration. See Apple Pay setup and keep the Card Element available when no wallet is eligible. An existing one-time wallet payment is not automatically converted into a subscription by upgrading the SDK.

1. Return authoritative plan details

Use an internal plan identifier in the browser, such as pro_monthly. Map it to the Paypercut Price on your backend so a customer cannot replace the amount, currency, or Price ID.

For example, your backend can return the details needed to render the signup page and configure Elements:

{
  "plan": "pro_monthly",
  "display_name": "Pro monthly",
  "amount": 2999,
  "currency": "EUR",
  "interval": "monthly"
}

amount uses the currency's minor unit. In this example, 2999 represents EUR 29.99 and should equal the first amount that the backend will attempt when creating the subscription.

2. Create an Elements Group in subscription mode

Install and import the browser SDK:

npm install @paypercut/checkout-js@^1.4.1
import { Paypercut } from '@paypercut/checkout-js';

Create the client and an Elements Group with mode: 'subscription':

const plan = await fetch('/api/billing/plans/pro_monthly')
  .then((response) => response.json());

const paypercut = Paypercut({
  publishableKey: 'YOUR_PUBLISHABLE_KEY',
});

const elements = paypercut.elements({
  mode: 'subscription',
  amount: plan.amount,
  currency: plan.currency,
  locale: 'auto',
});

Do not set setupFutureUsage to on_session. Subscription mode automatically uses off_session because renewal payments happen when the customer is not actively using your checkout page.

mode, currency, and the derived future-usage setting are immutable. Create a new Elements Group if the customer selects a plan with a different currency. If only the amount changes, update the displayed terms and call elements.update({ amount }) before a new collection attempt.

3. Create the Payment Method

Add wallet buttons, a card fallback, and merchant-owned recurring-billing terms. Show the amount and billing interval before asking for consent:

<form id="subscription-form">
  <p>Pro monthly: EUR 29.99 now and every month until canceled.</p>
  <label>
    <input id="recurring-consent" type="checkbox" required />
    I authorize recurring charges according to the subscription terms.
  </label>
  <div id="express-checkout"></div>
  <p>Or subscribe with a card:</p>
  <label>
    Name on card
    <input id="billing-name" autocomplete="cc-name" required />
  </label>
  <label>
    Email
    <input id="billing-email" type="email" autocomplete="email" required />
  </label>
  <div id="card-element"></div>
  <p id="subscription-error" role="alert"></p>
  <button id="subscribe-button" type="submit" disabled>Subscribe</button>
</form>

Both collection paths use the same merchant-owned backend endpoint. The endpoint names and response shape below are examples for your application, not Paypercut API routes:

const form = document.querySelector('#subscription-form');
const button = document.querySelector('#subscribe-button');
const errorMessage = document.querySelector('#subscription-error');
const consent = document.querySelector('#recurring-consent');
let cardReady = false;
let collecting = false;
let creatingSubscription = false;

function setCollecting(value) {
  collecting = value;
  consent.disabled = value;
  button.disabled = value || !cardReady || !consent.checked;
}

consent.addEventListener('change', () => setCollecting(collecting));

async function createSubscription(paymentMethodId) {
  creatingSubscription = true;
  try {
    const response = await fetch('/api/billing/subscriptions', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        plan: plan.plan,
        payment_method: paymentMethodId,
        recurring_consent: consent.checked,
      }),
    });

    const result = await response.json();
    if (!response.ok) throw new Error(result.message || 'Subscription creation failed.');
    if (result.status !== 'active' || !result.subscription_id) {
      throw new Error('Subscription success is not confirmed. Check its state before retrying.');
    }
    return result;
  } finally {
    creatingSubscription = false;
  }
}

Return subscription_id and status: 'active' only after your backend has confirmed the first payment and stored the Subscription. Your backend must deduplicate signup requests and reconcile unknown outcomes before allowing another charge; disabling buttons is not a substitute for server-side attempt tracking.

Collect with Express Checkout

In SDK 1.4.1, you can create Express Checkout in this subscription-mode group:

const expressCheckout = elements.create('expressCheckout', {
  paymentMethods: { applePay: 'auto', googlePay: 'auto' },
  emailRequired: true,
  billingAddressRequired: true,
});

expressCheckout.on('ready', ({ availablePaymentMethods }) => {
  document.querySelector('#express-checkout').hidden =
    !availablePaymentMethods.applePay && !availablePaymentMethods.googlePay;
});

expressCheckout.on('click', (event) => {
  if (collecting || !consent.checked) {
    errorMessage.textContent = 'Accept the subscription terms and finish any current attempt.';
    event.reject();
    return;
  }
  errorMessage.textContent = '';
  setCollecting(true);
  event.resolve();
});

expressCheckout.on('cancel', () => {
  if (!creatingSubscription) setCollecting(false);
});

expressCheckout.on('error', () => {
  errorMessage.textContent = 'Wallet collection is unavailable. Use the card form or try later.';
  if (!creatingSubscription) setCollecting(false);
});

expressCheckout.on('confirm', async (event) => {
  let result;
  try {
    result = await createSubscription(event.paymentMethod.id);
  } catch {
    const message = 'Subscription success is not confirmed. Check its state before retrying.';
    errorMessage.textContent = message;
    event.paymentFailed({ code: 'subscription_creation_failed', message });
    setCollecting(false);
    return;
  }

  event.complete();
  window.location.assign(`/billing/subscriptions/${encodeURIComponent(result.subscription_id)}`);
});

expressCheckout.mount('#express-checkout');

The confirm event already contains a Payment Method. Do not call elements.submit() or paypercut.createPaymentMethod() again for that wallet attempt. Keep the native wallet pending until the backend responds, then call exactly one of event.complete() or event.paymentFailed(). These callbacks settle the wallet UI; they do not create, cancel, or reverse a server-side payment.

Wallet authorization and Payment Method creation alone are not proof that the first subscription payment succeeded.

Collect with the Card Element

For the card fallback, validate the Elements Group before creating its Payment Method:

const card = elements.create('card');

card.on('ready', () => {
  cardReady = true;
  setCollecting(collecting);
});

card.on('error', () => {
  errorMessage.textContent = 'Payment details are unavailable. Reload the page or try later.';
});

card.mount('#card-element');

form.addEventListener('submit', async (event) => {
  event.preventDefault();
  if (collecting || !consent.checked) return;
  setCollecting(true);
  errorMessage.textContent = '';

  try {
    await elements.submit();

    const paymentMethod = await paypercut.createPaymentMethod({
      elements,
      params: {
        billing_details: {
          name: document.querySelector('#billing-name').value,
          email: document.querySelector('#billing-email').value,
        },
      },
    });

    const result = await createSubscription(paymentMethod.id);
    window.location.assign(`/billing/subscriptions/${encodeURIComponent(result.subscription_id)}`);
  } catch (error) {
    errorMessage.textContent =
      error instanceof Error ? error.message : 'Subscription creation failed.';
    setCollecting(false);
  }
});

createPaymentMethod() returns an opaque reference:

{
  "type": "payment_method",
  "id": "01KXXXXXXXXXXXXXXXXXXXXXXX"
}

Send the Payment Method ID, your internal plan selection, and the consent recorded by your application to the backend. Do not send raw card data or put the Payment Method ID in a URL, DOM attribute, analytics event, or application log.

4. Create the subscription on your backend

After receiving the browser request, your backend must:

  1. Authenticate the user.
  2. Load the Paypercut Customer ID associated with that user or account.
  3. Map the internal plan to an allowed recurring Price and authoritative amount, currency, and interval.
  4. Validate and record the customer's acceptance of the recurring terms.
  5. Deduplicate the logical signup attempt and create the Subscription with payment_behavior: 'error_if_incomplete' and the Payment Method as default_payment_method.
  6. Store the returned Subscription ID before responding to the browser.

This flow requires error_if_incomplete: the first payment prepares the newly collected method for recurring use and creates the Subscription only if that payment succeeds. Do not first charge it as a separate one-time purchase.

The following is a server-side request. The recurring_consent field in the browser example belongs to your application; record it on your backend, not as a Subscription API field.

curl https://api.paypercut.io/v1/subscriptions \
  -X POST \
  -H "Authorization: Bearer $PAYPERCUT_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: subscription:user_123:pro_monthly:v1" \
  -d '{
    "customer": "01KJQ34MWYH0TES77RDXA8T8TT",
    "currency": "EUR",
    "default_payment_method": "01KXXXXXXXXXXXXXXXXXXXXXXX",
    "payment_behavior": "error_if_incomplete",
    "livemode": false,
    "items": [
      {
        "price": "01HFRECURRINGPRICE000000000",
        "unit_amount": 2999,
        "currency": "EUR",
        "recurring": {
          "interval": "monthly",
          "interval_count": 1,
          "usage_type": "licensed"
        }
      }
    ],
    "metadata": {
      "internal_account_id": "user_123",
      "internal_plan": "pro_monthly"
    }
  }'

The public Subscriptions API requires the item's recurring object even when price references a catalog Price. The example also supplies unit_amount and item currency explicitly; keep them aligned with the Price stored in Paypercut.

This request uses test mode. Keep livemode and both API keys consistent; use livemode: true with live credentials when going live.

Use a stable idempotency key and a persisted merchant-side attempt record for one logical signup attempt. If a create response is lost or your backend times out, retrieve or reconcile the existing result before allowing another attempt. A failed wallet UI callback does not establish that a charge failed.

Handle the first payment result

With error_if_incomplete:

  • a successful first payment returns the created Subscription with status: active;
  • a failed first payment returns 402, and no Subscription is created;
  • after a confirmed payment failure, the customer can correct the card or choose another wallet card and start a deliberate new attempt.

Do not call paypercut.confirmPayment() in this flow. That browser method supports payment-mode Checkout Sessions, not direct Subscription creation.

Do not substitute default_incomplete or allow_incomplete for this freshly collected Payment Method. Those paths require a reusable method already attached to the Customer. Trials and deferred first payments need a separate setup/attachment flow; they are outside this guide. See Create subscriptions directly for flows that already have a reusable method.

5. Store IDs and process webhooks

Store:

  • your internal user, account, workspace, or tenant ID;
  • customer_id;
  • subscription_id;
  • price_id and internal plan mapping;
  • default_payment_method when useful for support;
  • invoice IDs received in lifecycle events;
  • webhook event or delivery IDs for idempotency.

At minimum, handle these verified events:

Event What to do
invoice.paid Grant or extend access for the paid billing period.
invoice.payment_failed Start recovery and retrieve the latest Subscription and invoice state.

Do not grant access only because the browser reached a success page. Use the authenticated Subscription response and process invoice events idempotently so delayed and renewal payments update the correct account.

What is saved for renewals

After a successful first subscription authorization, Paypercut associates the method with the Customer and persists the provider credential and recurring-payment evidence needed for subsequent charges. The Subscription's default_payment_method identifies the method used for renewals. The customer does not need to reopen Apple Pay or Google Pay for each scheduled charge.

Future payments can still fail or require recovery. Neither mode: 'subscription' nor a completed wallet UI guarantees that every renewal will succeed. Keep your invoice webhook and recovery flow active.

SDK 1.4.0 restricted Express Checkout to payment-mode groups. SDK 1.4.1 removes that restriction. Use subscription mode before collecting the wallet method and complete the backend flow above; do not treat an unrelated one-time wallet payment as automatic subscription setup.

Test the integration

Before going live, test with sandbox credentials:

  1. Create an immediate subscription with a newly collected card, Apple Pay, and Google Pay method on eligible devices.
  2. Confirm that a successful first payment returns an active Subscription and that its default method belongs to the expected Customer.
  3. Confirm that the wallet UI settles exactly once and that a declined first payment does not create a Subscription.
  4. Simulate a lost backend response and reconcile it without creating another charge or Subscription.
  5. Exercise a renewal using the saved default method without opening a wallet, and process invoice.paid and invoice.payment_failed idempotently.

Wallet collection tests alone do not verify recurring billing. Test the first authorization and a subsequent renewal for the wallet methods you intend to offer.

Common mistakes

  • Using SDK 1.4.0 for subscription-mode Express Checkout.
  • Assuming mode: 'subscription' creates the Subscription in the browser.
  • Treating wallet authorization or event.complete() as proof of a paid subscription.
  • Calling elements.submit() or createPaymentMethod() again for a wallet method received in confirm.
  • Omitting error_if_incomplete or adding a trial/deferred first charge to this flow.
  • Sending an amount, currency, Price ID, or Customer ID from the browser and trusting it without backend validation.
  • Setting setupFutureUsage: 'on_session' in subscription mode.
  • Calling paypercut.confirmPayment() after direct Subscription creation.
  • Retrying a timed-out create request with a new idempotency key before reconciling the first attempt.
  • Treating a frontend redirect as proof that a billing period was paid.
  • Ignoring invoice.payment_failed and renewal recovery.