subbydocs
Visit Subby Start building

@mysubbyapp/checkout

Embed Subby checkout in your product with the @mysubbyapp/checkout SDK. Create a session on your server, mount the iframe, and grant access from webhooks.

@mysubbyapp/checkout is the browser SDK for embedded subscription checkout. Your server creates a checkout session with a secret key. The SDK mounts a Subby-hosted iframe, collects the mandate, and tells your page when authorisation is pending.

The SDK is not a payment engine. Payment rails collect the charge. Subby's subscription engine attaches the mandate to the plan and sends lifecycle events to your webhooks.

Installation

npm

npm install @mysubbyapp/checkout
yarn add @mysubbyapp/checkout
pnpm add @mysubbyapp/checkout

Browser (CDN)

<script src="https://cdn.jsdelivr.net/npm/@mysubbyapp/checkout@latest/dist/index.umd.js"></script>

The UMD build is available as window.Subby. Pin a version in production instead of @latest.

<script src="https://cdn.jsdelivr.net/npm/@mysubbyapp/checkout@0.1.0/dist/index.umd.js"></script>

Before you start

  1. Create a plan and a customer in sandbox.
  2. Copy your publishable key from Dashboard → Developers → API keys. It starts with pk_test_ in sandbox and pk_live_ in live.
  3. Add your site origin to the key's domain allow-list (for example https://app.yoursite.com).
  4. Keep your secret key (sk_test_ / sk_live_) on the server. Never send it to the browser.

Use publishable keys only in frontend code. Create sessions, store metadata and grant access from your backend.

Create a checkout session

Embedded checkout needs a short-lived client secret from your server. Hosted checkout uses the same session's url — see Subscription checkout.

// Node.js — your backend
app.post('/api/checkout', async (req, res) => {
  const response = await fetch(`${process.env.SUBBY_BASE_URL}/checkout-sessions`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.SUBBY_SECRET_KEY}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': crypto.randomUUID(),
    },
    body: JSON.stringify({
      mode: 'subscription',
      customer: req.user.subbyCustomerId,
      plan: req.body.planId,
      success_url: `${process.env.SITE_URL}/billing/success`,
      cancel_url: `${process.env.SITE_URL}/billing/cancelled`,
      metadata: { your_user_id: req.user.id },
    }),
  });

  const { data: session } = await response.json();
  res.json({
    client_secret: session.client_secret,
    session_id: session.id,
  });
});

POST /checkout-sessions is a metered write. See Checkout sessions for the full request.

Do not grant service when the iframe reports mandate_pending. Wait for subscription.created on your webhook endpoint. Banks can take from a few minutes up to 24 hours to confirm the mandate.

Quick start

Vanilla JavaScript

import Subby from '@mysubbyapp/checkout';

const subby = Subby('pk_test_YOUR_KEY');

const response = await fetch('/api/checkout', { method: 'POST' });
const { client_secret } = await response.json();

const checkout = subby.checkout(client_secret, {
  theme: 'light',
  accentColor: '#0B5FFF',
  borderRadius: 8,
});

checkout.mount('#checkout-container');

checkout.on('mandate_pending', ({ subscription }) => {
  window.location = `/billing/complete?subscription=${subscription.id}`;
});

checkout.on('error', ({ code, message }) => {
  console.error(`[${code}] ${message}`);
});

React

import { useEffect, useRef, useState } from 'react';
import Subby from '@mysubbyapp/checkout';

export function CheckoutPage() {
  const containerRef = useRef(null);
  const [clientSecret, setClientSecret] = useState('');

  useEffect(() => {
    fetch('/api/checkout', { method: 'POST' })
      .then((r) => r.json())
      .then(({ client_secret }) => setClientSecret(client_secret));
  }, []);

  useEffect(() => {
    if (!clientSecret || !containerRef.current) return;

    const subby = Subby('pk_test_YOUR_KEY');
    const checkout = subby.checkout(clientSecret, {
      theme: 'light',
      accentColor: '#0B5FFF',
    });

    checkout.mount(containerRef.current);

    checkout.on('mandate_pending', ({ subscription }) => {
      console.log('Authorisation received:', subscription.id);
    });

    checkout.on('error', ({ message }) => {
      console.error(message);
    });

    return () => checkout.destroy();
  }, [clientSecret]);

  return (
    <div>
      <h1>Subscribe to Pro</h1>
      <div ref={containerRef} />
    </div>
  );
}

Vue

<template>
  <div class="checkout-page">
    <h1>Subscribe to Pro</h1>
    <div ref="checkoutContainer" />
  </div>
</template>

<script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue';
import Subby from '@mysubbyapp/checkout';

const checkoutContainer = ref(null);
let checkout = null;

onMounted(async () => {
  const response = await fetch('/api/checkout', { method: 'POST' });
  const { client_secret } = await response.json();

  const subby = Subby('pk_test_YOUR_KEY');
  checkout = subby.checkout(client_secret, {
    theme: 'light',
  });

  checkout.mount(checkoutContainer.value);

  checkout.on('mandate_pending', ({ subscription }) => {
    console.log('Authorisation received:', subscription.id);
  });
});

onBeforeUnmount(() => {
  checkout?.destroy();
});
</script>

WordPress and Shopify teams can skip the SDK and use the WordPress plugin or Shopify app instead.

API reference

Subby(publishableKey, options?)

Initialise the SDK with a publishable key.

Parameters

  • publishableKey (string, required) — pk_test_* or pk_live_* from Dashboard → Developers → API keys.
  • options (object, optional)
    • apiUrl (string) — API origin. Defaults to https://api.mysubbyapp.com.
    • environment (string) — 'test' or 'live'. Inferred from the key prefix if omitted.
    • locale (string) — Default language. Defaults to 'en'.
    • timeout (number) — Request timeout in milliseconds. Defaults to 30000.

Returns a SubbyCheckoutSDK instance.

const subby = Subby('pk_live_abc123def456', {
  environment: 'live',
  locale: 'en-NG',
  timeout: 30000,
});

subby.checkout(clientSecret, appearance?)

Create a checkout element.

Parameters

  • clientSecret (string, required) — From your backend checkout-session response.
  • appearance (object, optional)
    • theme — 'light', 'dark', or 'auto' (default 'auto').
    • accentColor — Brand colour (default '#0B5FFF').
    • borderRadius — 0–12 (default 8).
    • font — 'inherit' or a font-family list (default 'inherit').
    • variables — Optional CSS variable overrides.

Returns a CheckoutElement.

const checkout = subby.checkout('cs_secret_abc123', {
  theme: 'dark',
  accentColor: '#FF6B6B',
  borderRadius: 12,
  font: 'system-ui, -apple-system, sans-serif',
  variables: {
    primaryColor: '#333',
    errorColor: '#DC2626',
  },
});

checkout.mount(selector | element)

Render the iframe into the page. Pass a CSS selector or an HTMLElement.

checkout.mount('#checkout-container');
checkout.mount(document.getElementById('checkout'));

checkout.unmount()

Remove the iframe but keep the instance so you can mount it again.

checkout.destroy()

Tear down the iframe and all listeners. The instance cannot be reused.

checkout.updateAppearance(config)

Change theme or colours after mount — for example when the host page switches dark mode.

checkout.updateAppearance({
  theme: 'dark',
  accentColor: '#FF6B6B',
});

checkout.on(event, handler) / checkout.off(event, handler)

Subscribe to or remove a checkout event. Pass the same function reference to off.

const handleError = ({ message }) => console.error(message);
checkout.on('error', handleError);
checkout.off('error', handleError);

Events

Checkout events are browser events. They are not webhooks. Use them to update UI. Use signed webhooks to change access in your product.

Typical order:

  1. ready — iframe loaded.
  2. bank_selected — subscriber picked a bank.
  3. authorisation_required — transfer instructions shown (complete within the window).
  4. mandate_pending — transfer received; bank confirmation can take 5 minutes to 24 hours.
  5. completed — mandate already active (uncommon; some rails confirm immediately).

The subscriber can also fire cancelled, expired, or error at any point after ready.

ready

{ type: 'ready', height: 450 }

The iframe has loaded. Use height if you size the container yourself; the iframe also auto-resizes.

bank_selected

{
  type: 'bank_selected',
  bank: { code: '044', name: 'Access Bank' }
}

authorisation_required

The rail needs an authorisation transfer. Complete it before expiresAt.

{
  type: 'authorisation_required',
  reference: 'AUTH-12345',
  amount: 5000,
  expiresAt: '2026-01-15T10:30:00Z',
  bankName: 'Access Bank',
  accountName: 'Ada Okafor'
}

amount is in kobo.

mandate_pending

Authorisation was received. The mandate is waiting for the bank. Do not grant service yet.

{
  type: 'mandate_pending',
  subscription: {
    id: 'sub_123abc',
    customerId: 'cus_456def',
    productId: 'plan_789ghi',
    status: 'incomplete',
    plan: 'monthly'
  },
  mandate: {
    id: 'mdt_123xyz',
    status: 'awaiting_authorisation',
    ceilingAmount: 600000,
    reference: 'AUTH-12345'
  },
  activationExpectedBy: '2026-01-15T18:30:00Z'
}

Show a confirmation page. Grant access when your backend receives subscription.created.

completed

The mandate is already active (rare). Still confirm with webhooks before changing entitlements.

error

{
  type: 'error',
  code: 'network_error',
  message: 'Request timeout',
  recoverable: true,
  details: {}
}

expired

The session or authorisation window ended. Create a new checkout session and remount.

{ type: 'expired', reason: 'session_expired' }

reason is session_expired or authorization_window_expired.

cancelled

The subscriber closed checkout.

Error handling

These codes come from the checkout iframe, not from the REST error catalog.

Code Recoverable What to do
client_secret_invalid No Create a new checkout session
publishable_key_domain_not_allowed No Add the page origin to the publishable key allow-list
ui_mode_not_supported No Use embedded mode with @mysubbyapp/checkout
mandate_ceiling_too_low No Raise the mandate ceiling on the session
account_name_mismatch Yes Ask the subscriber to match the account-holder name
authorisation_window_expired Yes Create a new session and remount
mandate_not_active Yes Wait for activation; do not grant access yet
rail_unavailable Yes Retry with backoff
network_error Yes Retry with backoff
checkout.on('error', ({ code, message, recoverable }) => {
  switch (code) {
    case 'publishable_key_domain_not_allowed':
      showBanner('This domain is not on the checkout allow-list.');
      break;
    case 'account_name_mismatch':
      showBanner('The account name must match the bank records.');
      break;
    case 'authorisation_window_expired':
      showBanner('The authorisation window closed. Starting a new checkout…');
      break;
    case 'network_error':
    case 'rail_unavailable':
      if (recoverable) showBanner('Temporarily unavailable. You can retry.');
      break;
    default:
      showBanner(message);
  }
});

Appearance

The iframe resizes to its content, follows the container width, and adapts to mobile viewports. You do not need extra layout configuration.

const checkout = subby.checkout(clientSecret, {
  theme: 'light',
  accentColor: '#0B5FFF',
  borderRadius: 8,
  font: 'inherit',
  variables: {
    primaryColor: '#333',
    backgroundColor: '#fff',
    errorColor: '#DC2626',
    successColor: '#10B981',
    borderColor: '#E5E7EB',
    textColor: '#111827',
  },
});

TypeScript

The package ships type definitions.

import type {
  CheckoutElement,
  CheckoutEvent,
  Mandate,
  SubbyCheckoutSDK,
  Subscription,
} from '@mysubbyapp/checkout';
import Subby from '@mysubbyapp/checkout';

const subby: SubbyCheckoutSDK = Subby('pk_live_xxx');
const checkout: CheckoutElement = subby.checkout('cs_secret_xxx');

checkout.on('mandate_pending', (event: CheckoutEvent) => {
  if (event.type !== 'mandate_pending') return;
  const subscription: Subscription = event.subscription;
  const mandate: Mandate = event.mandate;
});

If the compiler complains about the package types, set skipLibCheck and esModuleInterop in tsconfig.json.

Security

  1. Publishable keys in the browser only. Never initialise Subby() with sk_live_ or sk_test_.
  2. Create sessions on the server. Do not let the browser choose plan amounts or mandate ceilings.
  3. Verify webhooks with the whsec_ secret before granting access. See Webhooks.
  4. Serve the host page over HTTPS in live.
  5. Allow the checkout iframe in CSP:
frame-src https://checkout.mysubbyapp.com
script-src 'self' https://cdn.jsdelivr.net

Full example

const subby = Subby('pk_test_YOUR_KEY');

async function createCheckoutSession(planId) {
  const response = await fetch('/api/checkout', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ planId }),
  });
  if (!response.ok) throw new Error('Failed to create session');
  return response.json();
}

async function initCheckout() {
  const { client_secret } = await createCheckoutSession('plan_8ZpR1v');
  const checkout = subby.checkout(client_secret, {
    theme: 'light',
    accentColor: '#0B5FFF',
  });

  checkout.mount('#checkout-container');

  checkout.on('ready', () => {
    document.getElementById('loading').hidden = true;
  });

  checkout.on('mandate_pending', ({ subscription, activationExpectedBy }) => {
    showSuccess(
      `Payment authorised. Your subscription ${subscription.id} activates by ${activationExpectedBy}.`,
    );
  });

  checkout.on('error', ({ code, message, recoverable }) => {
    showError(recoverable ? `${message} You can retry.` : message);
  });

  checkout.on('expired', () => {
    showError('Checkout expired. Refresh to start again.');
  });
}

document.addEventListener('DOMContentLoaded', initCheckout);

Browser support

Browser Version
Chrome 60+
Firefox 55+
Safari / Mobile Safari 12+
Edge 79+
Chrome Mobile 60+
Samsung Internet 8+

Older browsers may need polyfills for fetch, Promise and AbortController.

Troubleshooting

Iframe does not render. Confirm #checkout-container exists before mount, the client secret is not expired, and the console has no origin errors.

origin is not allowed. Add the exact page origin (scheme + host) to Dashboard → Developers → API keys → Domain allow-list. Wait a few minutes, then refresh.

Network errors. Check the request reaches https://api.mysubbyapp.com/v1 (or the sandbox base URL), CORS allows your origin, and you did not send a secret key from the browser.

Layout issues on mobile. Include <meta name="viewport" content="width=device-width, initial-scale=1"> and let the container be width: 100%. Test on a device, not only DevTools.

Frequently asked questions

Can I create a checkout session in the browser?

No. Call POST /checkout-sessions from your server with a secret key. Pass only client_secret to the SDK.

When should I grant access?

On mandate_pending, tell the subscriber they are authorised. Grant service when subscription.created arrives at your webhook endpoint.

Can I poll instead of using webhooks?

Use webhooks. Polling wastes write allowance and still misses mandate activation. See Webhooks and API usage.

Next steps