@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
- Create a plan and a customer in sandbox.
- Copy your publishable key from Dashboard → Developers → API keys. It starts with
pk_test_in sandbox andpk_live_in live. - Add your site origin to the key's domain allow-list (for example
https://app.yoursite.com). - 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 forsubscription.createdon 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_*orpk_live_*from Dashboard → Developers → API keys.options(object, optional)apiUrl(string) — API origin. Defaults tohttps://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 to30000.
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(default8).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:
ready— iframe loaded.bank_selected— subscriber picked a bank.authorisation_required— transfer instructions shown (complete within the window).mandate_pending— transfer received; bank confirmation can take 5 minutes to 24 hours.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
- Publishable keys in the browser only. Never initialise
Subby()withsk_live_orsk_test_. - Create sessions on the server. Do not let the browser choose plan amounts or mandate ceilings.
- Verify webhooks with the
whsec_secret before granting access. See Webhooks. - Serve the host page over HTTPS in live.
- 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
- WordPress plugin — Embed checkout with a shortcode.
- Shopify app — Enable Subby on selected products.
- Webhooks — Verify signatures and handle lifecycle events.
- Going live — Move from sandbox keys to live keys.