Getting Started with the Payin SDK
Collect payments, save cards, and run subscriptions from your own checkout — with a few lines of code.
Getting Started with the Payin SDK
@paymentlabs/paymentlabs-js is a framework-agnostic TypeScript SDK that drops a secure, PCI-compliant payment form into your web app. You bring the checkout page; the SDK handles everything from the card number field to the final transaction.
This guide takes you from zero to a working payment in about five minutes, then digs into the things you'll reach for next: saving a card without charging it, subscriptions, theming, and handling results.
On this page
Get started
Common flows
Reacting to the widget
Integrate & reference
- Framework guides — React, Vue, vanilla JS, CDN
- API reference
- Type reference
- Going live
- Support
How it works
Think of a payment as a short conversation between three parties:
YOUR APP PAYMENT LABS SDK PAYMENT LABS
┌──────────────┐ ┌────────────────────┐ ┌────────────────┐
│ Product page │ │ Payment widget │ │ Checkout API │
│ Cart & total │ ───────▶ │ Secure card fields│ ────▶ │ Tokenization │
│ "Pay" button │ │ Events & results │ │ Processing │
└──────────────┘ └────────────────────┘ └────────────────┘
You design The SDK collects We move the money
the experience & tokenizes safely and tell you what
happened
- Your backend creates a checkout (the intent to collect money or save a card) and gets back a checkout ID.
- Your frontend hands that ID to the SDK, which renders the payment widget inside a container you choose.
- The buyer enters their details. The SDK tokenizes them securely (your servers never touch raw card data) and submits the transaction.
- The SDK tells you what happened through events —
checkout-completed,checkout-failed, and so on — so you can react in your UI.
What the SDK does — and doesn't do
The SDK is laser-focused on collecting and processing the payment. It deliberately leaves the rest of the checkout to you.
| The SDK handles | You handle |
|---|---|
| Secure card / bank / wallet input fields | Product pages, cart, and order summary |
| PCI-compliant tokenization & submission | Pricing, tax, and shipping logic |
| Payment, save-card, and subscription flows | Order creation and fulfillment |
| Real-time events and result screens | The "Pay" button and surrounding UI |
| Theming to match your brand | Creating the checkout on your backend |
The golden ruleThe SDK never collects money on its own. It always works against a checkout that you created first through the Payment Labs API. That checkout decides the amount, the currency, and which payment methods appear.
Prerequisites
Before you write any frontend code, make sure you have:
- A Payment Labs account with a merchant ID (looks like
C-220c070ec91944b8ad39814ec43d9c05). - A checkout, created from your backend via the API. You'll get back a checkout ID such as
S-01ARZ3NDEKTSV4RRFFQ69G5FAV.
Creating the checkout on the server is what keeps the amount tamper-proof — a buyer can never change what they owe from the browser.
- One-time payments → Create Checkout API
- Subscriptions → Create a Subscription Plan, then a Plan Offer. (See the Subscriptions guide.)
Install
Add the SDK with your package manager of choice:
npm install @paymentlabs/paymentlabs-js
# or
yarn add @paymentlabs/paymentlabs-js
# or
pnpm add @paymentlabs/paymentlabs-jsPrefer a script tag? Load it from our CDN — PaymentLabs becomes available on window:
<script src="https://sdk.paymentlabs.io/payin/latest/paymentlabs.js"></script>
Pin a version in production
latestis great while you build. Before you ship, pin an exact version so a future release can never change your checkout unexpectedly:https://sdk.paymentlabs.io/payin/v1.2.3/paymentlabs.js
Quickstart: your first payment
Here's the whole flow end to end. There are only three moving parts.
Step 1 — Create a checkout (backend)
From your server, call the Create Checkout API with the amount, currency, and buyer. Send the returned checkout ID down to your page.
Step 2 — Render the widget (frontend)
Initialize the SDK, then mount a widget into a container element.
import { PaymentLabs } from "@paymentlabs/paymentlabs-js";
// 1. Create the SDK once, with your environment and merchant ID.
const sdk = PaymentLabs.create({
environment: "sandbox", // switch to "production" when you go live
merchantId: "C-220c070ec91944b8ad39814ec43d9c05",
});
// 2. Mount the widget into an element on your page.
const widget = await sdk.createWidget("checkout", checkoutId, {
containerId: "checkout-container", // <div id="checkout-container"></div>
});<div id="checkout-container"></div>
<button id="pay-button" disabled>Pay</button>Step 3 — React to the result
The widget tells you when it's ready, when it's working, and how it ended. Wire up a button to submit, and listen for the outcome.
const payButton = document.getElementById("pay-button");
// The form is rendered and interactive.
widget.on("checkout-loaded", () => {
payButton.disabled = false;
});
// Reflect progress so the buyer knows something's happening.
widget.on("payment-processing", (isProcessing) => {
payButton.disabled = isProcessing;
payButton.textContent = isProcessing ? "Processing…" : "Pay";
});
// The happy path 🎉
widget.on("checkout-completed", () => {
showSuccessScreen();
});
// Something went wrong — tell the buyer what to do next.
widget.on("checkout-failed", ({ errorReason }) => {
showError(errorReason);
});
// Hand control to the SDK when the buyer clicks Pay.
payButton.addEventListener("click", () => widget.submitPayment());That's it. The SDK rendered the form, collected and tokenized the card, ran the transaction, and told you how it went. Everything from here is refinement.
Saving a card without charging it
Sometimes you don't want to charge the buyer right now — you just want their card on file for later: a free trial, a "pay after delivery" model, or simply letting a customer add a payment method to their account.
The SDK does this with a beautiful trick: create the checkout with an amount of 0.
When the amount is zero, the same checkout widget quietly becomes a save-card experience:
- The buyer enters their card exactly as usual.
- On submit, the card is securely tokenized and saved — no money moves.
- The SDK adapts its language automatically: progress reads "Your request is being processed" and success reads "Your request has been successfully completed" instead of talking about a payment.
- The optional "save this card for later" checkbox is hidden — saving the card is the whole point, so there's nothing to opt into.
Why amount = 0?A zero-amount checkout expresses intent ("collect and store a payment method") without a charge. You can charge that saved method later, server-side, whenever you're ready.
How to do it
There is no special frontend flag. Your code is identical to a normal checkout — the only difference is that your backend created the checkout with amount: 0.
// Backend created this checkout with an amount of 0.
const widget = await sdk.createWidget("checkout", savecardCheckoutId, {
containerId: "checkout-container",
});
const saveButton = document.getElementById("save-card-button");
widget.on("checkout-loaded", () => {
saveButton.disabled = false;
});
// Fired the same way — the card is now saved and reusable.
widget.on("checkout-completed", () => {
showCardSavedScreen();
});
saveButton.addEventListener("click", () => widget.submitPayment());<div id="checkout-container"></div>
<!-- Label the action for what it really is -->
<button id="save-card-button" disabled>Save card</button>
One small UX tipSince nothing is being charged, label your button "Save card" (or "Add payment method") rather than "Pay". The SDK already adjusts its own copy; matching your button keeps the experience honest.
When you're ready to charge a saved card, you do it from your backend using the stored payment method — see the API reference for charging an existing instrument.
Subscriptions
Recurring billing follows the same shape as a one-time payment, with one difference: instead of a checkout ID, you pass a plan offer ID, and you create the widget with the "subscription" type.
const widget = await sdk.createWidget("subscription", planOfferId, {
containerId: "subscription-container",
});
widget.on("subscription-loaded", () => {
const offer = widget.getSubscriptionOfferData();
console.log("Plans available:", offer?.subscriptionPlans);
});
// The buyer picked a different plan from the offer.
widget.on("subscription-plan-changed", (plan) => {
updatePlanSummary(plan); // plan.name, plan.amount.formattedValue, plan.schedule…
});
widget.on("subscription-completed", () => {
showSubscriptionActiveScreen();
});
widget.on("subscription-failed", ({ errorReason }) => {
showError(errorReason);
});
submitButton.addEventListener("click", () => widget.submitPayment());A plan offer can contain several plans (e.g. Monthly and Annual). The widget renders them for the buyer to choose from, and fires subscription-plan-changed whenever the selection changes so you can keep your own summary in sync.
New to subscriptions? Start with the Getting Started with Subscriptions guide, which covers creating plans and offers.
Collecting the billing address
By default, the SDK renders the billing address fields for you inside the widget — you don't have to do anything. But if you already collect the address elsewhere (say, a shipping form), you can turn the SDK's fields off and pass the address in at submit time.
Option A — Let the SDK collect it (default)
const widget = await sdk.createWidget("checkout", checkoutId, {
containerId: "checkout-container",
});
await widget.submitPayment(); // address comes from the SDK-rendered fieldsOption B — Provide it yourself
Set billingDetails.fields.address to false, then include the address in submitPayment():
const widget = await sdk.createWidget("checkout", checkoutId, {
containerId: "checkout-container",
billingDetails: {
fields: { address: false }, // SDK won't render address fields
},
});
await widget.submitPayment({
billingDetails: {
address: {
address1: "123 Main Street",
address2: "Apt 4B", // optional
city: "New York",
state: "NY",
postalCode: "10001",
country: "US", // ISO 3166-1 alpha-2
},
},
});
If you turn the fields off, you must pass the address.When
addressisfalseand you callsubmitPayment()without one, the call fails fast with:"Address information is required but not provided".
fields.address | Who renders the address | Where the data comes from |
|---|---|---|
true (default) | The SDK | The widget's own fields |
false | You | The submitPayment() argument |
Theming
Match the widget to your brand with a small set of seed colors. The SDK derives a full, accessible palette from them.
const widget = await sdk.createWidget("checkout", checkoutId, {
containerId: "checkout-container",
theme: {
mode: "light", // "light" (default) or "dark"
colors: {
seed: {
brand: "#5b3df5", // your primary color — buttons, accents
neutral: "#1f2937", // text and neutral UI
info: "#2563eb",
positive: "#16a34a", // success
negative: "#dc2626", // errors
warning: "#d97706",
},
// Optional fine-grained overrides
overrides: {
"bg-main": "#ffffff",
},
},
},
});You can switch between light and dark at runtime from the SDK instance:
await sdk.updateTheme("dark");
Heads up: switching theme re-renders the secure fieldsFor security, the card fields (cardholder name, number, expiry, CVC) live in isolated iframes that must be rebuilt when the theme changes. Anything the buyer already typed into those fields will be cleared. Switch the theme before the buyer starts entering card details, not after.
Events
Events are how the widget talks back to you. Register a handler with widget.on(event, callback).
Checkout events
| Event | Fires when | Payload |
|---|---|---|
checkout-loaded | The form is rendered and ready | — |
payment-processing | Submission starts/stops | boolean (true = busy) |
checkout-processing | The charge is underway | — |
checkout-completed | Payment (or card save) succeeded | — |
checkout-failed | Payment failed | { errorReason, extendedErrorReason? } |
checkout-expired | The checkout's time window passed | — |
payment-failed | A pre-charge step failed (e.g. validation) | — |
Subscription events
| Event | Fires when | Payload |
|---|---|---|
subscription-loaded | The offer is rendered and ready | — |
subscription-plan-changed | The buyer selects a different plan | SubscriptionPlan |
subscription-processing | The subscription is being set up | — |
payment-processing | Submission starts/stops | boolean |
subscription-completed | The subscription was created | — |
subscription-failed | Subscription setup failed | { errorReason, extendedErrorReason? } |
subscription-expired | The offer's time window passed | — |
// Register many at once
["checkout-loaded", "checkout-completed", "checkout-failed"].forEach((event) =>
widget.on(event, (data) => analytics.track(event, data)),
);Handling errors gracefully
When a payment fails, you get an errorReason (the category) and sometimes an extendedErrorReason (the specific cause). Use the category to decide what to tell the buyer, and the extended reason to get specific when you can.
widget.on("checkout-failed", ({ errorReason, extendedErrorReason }) => {
switch (errorReason) {
case "MISSING_INSTRUMENT":
show("Please choose a payment method to continue.");
break;
case "INVALID_INSTRUMENT":
show("Those payment details look off. Please double-check them.");
break;
case "CHARGE_INSTRUMENT_ERROR":
show("Your card was declined. Try another card or contact your bank.");
break;
case "APPLICATION_ERROR":
show("Something went wrong on our side. Please try again shortly.");
break;
default:
show("Payment couldn't be completed. Please try again.");
}
if (extendedErrorReason) console.debug("Decline detail:", extendedErrorReason);
});errorReason — the high-level category:
| Value | Meaning |
|---|---|
MISSING_INSTRUMENT | No payment method was provided |
INVALID_INSTRUMENT | The payment method was invalid |
CHARGE_INSTRUMENT_ERROR | The instrument couldn't be charged (e.g. declined) |
APPLICATION_ERROR | An error occurred on the processing side |
extendedErrorReason (optional) — the specific cause, useful for logging and tailored messaging: INSUFFICIENT_FUNDS, EXPIRED_CARD, INVALID_CVV, DO_NOT_HONOR, TRANSACTION_NOT_PERMITTED, RESTRICTED_CARD, DUPLICATE_TRANSACTION, INVALID_ACCOUNT_NUMBER, INVALID_AMOUNT, INVALID_CURRENCY, CALL_ISSUER, ADDRESS_VERIFICATION_FAILED, MERCHANT_RULE_VALIDATION, THREE_D_SECURE_REQUIRED, THREE_D_SECURE_FAILED, THREE_D_SECURE_EXPIRED, UNKNOWN_ERROR.
Framework guides
The SDK is framework-agnostic. The pattern is always the same: create the SDK, create the widget into a container, listen for events, call submitPayment(). Here it is in the most common setups.
React
import { useEffect, useRef, useState } from "react";
import { PaymentLabs, PaymentWidget } from "@paymentlabs/paymentlabs-js";
export function Checkout({
checkoutId,
onSuccess,
onFailure,
}: {
checkoutId: string;
onSuccess: () => void;
onFailure: (reason: string) => void;
}) {
const widgetRef = useRef<PaymentWidget | null>(null);
const [ready, setReady] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
let widget: PaymentWidget | undefined;
(async () => {
const sdk = PaymentLabs.create({
environment: "sandbox",
merchantId: "C-220c070ec91944b8ad39814ec43d9c05",
});
widget = await sdk.createWidget("checkout", checkoutId, {
containerId: "checkout-container",
});
widgetRef.current = widget;
widget.on("checkout-loaded", () => setReady(true));
widget.on("payment-processing", setLoading);
widget.on("checkout-completed", onSuccess);
widget.on("checkout-failed", ({ errorReason }) => onFailure(errorReason));
})();
return () => widget?.destroy();
}, [checkoutId, onSuccess, onFailure]);
return (
<div>
<div id="checkout-container" />
{ready && (
<button onClick={() => widgetRef.current?.submitPayment()} disabled={loading}>
{loading ? "Processing…" : "Pay"}
</button>
)}
</div>
);
}Vue
<template>
<div>
<div id="checkout-container"></div>
<button v-if="ready" @click="submit" :disabled="loading">
{{ loading ? "Processing…" : "Pay" }}
</button>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue";
import { PaymentLabs, PaymentWidget } from "@paymentlabs/paymentlabs-js";
const props = defineProps<{ checkoutId: string }>();
const emit = defineEmits<{ success: []; failure: [reason: string] }>();
const widget = ref<PaymentWidget | null>(null);
const ready = ref(false);
const loading = ref(false);
onMounted(async () => {
const sdk = PaymentLabs.create({
environment: "sandbox",
merchantId: "C-220c070ec91944b8ad39814ec43d9c05",
});
widget.value = await sdk.createWidget("checkout", props.checkoutId, {
containerId: "checkout-container",
});
widget.value.on("checkout-loaded", () => (ready.value = true));
widget.value.on("payment-processing", (p) => (loading.value = p));
widget.value.on("checkout-completed", () => emit("success"));
widget.value.on("checkout-failed", ({ errorReason }) => emit("failure", errorReason));
});
onUnmounted(() => widget.value?.destroy());
const submit = () => widget.value?.submitPayment();
</script>Vanilla JavaScript (module import)
<div id="checkout-container"></div>
<button id="pay-button" disabled>Pay</button>
<script type="module">
import { PaymentLabs } from "@paymentlabs/paymentlabs-js";
const sdk = PaymentLabs.create({
environment: "sandbox",
merchantId: "C-220c070ec91944b8ad39814ec43d9c05",
});
const widget = await sdk.createWidget("checkout", "your-checkout-id", {
containerId: "checkout-container",
});
const payButton = document.getElementById("pay-button");
widget.on("checkout-loaded", () => (payButton.disabled = false));
widget.on("checkout-completed", () => alert("Payment complete 🎉"));
widget.on("checkout-failed", ({ errorReason }) => alert(`Failed: ${errorReason}`));
payButton.addEventListener("click", () => widget.submitPayment());
</script>CDN (script tag)
<!DOCTYPE html>
<html>
<body>
<div id="checkout-container"></div>
<button id="pay-button" disabled>Pay</button>
<script src="https://sdk.paymentlabs.io/payin/latest/paymentlabs.js"></script>
<script>
(async () => {
// PaymentLabs is available globally from the CDN.
const sdk = PaymentLabs.create({
environment: "production",
merchantId: "C-220c070ec91944b8ad39814ec43d9c05",
});
const widget = await sdk.createWidget("checkout", "your-checkout-id", {
containerId: "checkout-container",
});
const payButton = document.getElementById("pay-button");
widget.on("checkout-loaded", () => (payButton.disabled = false));
widget.on("payment-processing", (busy) => {
payButton.disabled = busy;
payButton.textContent = busy ? "Processing…" : "Pay";
});
widget.on("checkout-completed", () => alert("Payment complete 🎉"));
widget.on("checkout-failed", (e) => alert(`Failed: ${e.errorReason}`));
payButton.addEventListener("click", () => widget.submitPayment());
})();
</script>
</body>
</html>API reference
PaymentLabs.create(config)
PaymentLabs.create(config)Creates an SDK instance. Call it once and reuse it.
const sdk = PaymentLabs.create({
environment: "sandbox", // "sandbox" | "production"
merchantId: "C-220c070ec91944b8ad39814ec43d9c05",
});| Field | Type | Description |
|---|---|---|
environment | "sandbox" | "production" | Which Payment Labs environment to talk to |
merchantId | string | Your merchant identifier |
Returns a PaymentLabs instance.
sdk.createWidget(type, id, options)
sdk.createWidget(type, id, options)Creates and mounts a payment widget. Returns Promise<PaymentWidget>.
| Parameter | Type | Description |
|---|---|---|
type | "checkout" | "subscription" | The kind of widget to render |
id | string | A checkout ID (for "checkout") or plan offer ID (for "subscription") |
options | WidgetOptions | See below |
interface WidgetOptions {
containerId: string; // ID of the DOM element to render into (required)
theme?: ThemeConfig; // Brand colors and light/dark mode
billingDetails?: BillingDetailsConfig; // Address collection behavior
}sdk.updateTheme(mode)
sdk.updateTheme(mode)Switches the widget between "light" and "dark". ⚠️ Re-renders secure card fields (see Theming).
widget.submitPayment(request?)
widget.submitPayment(request?)Submits the payment (or saves the card, for a zero-amount checkout). Returns a result object.
const result = await widget.submitPayment();
// { success: boolean; instrumentId?: string; transactionId?: string; error?: string }Pass request.billingDetails.address only when you've disabled the SDK's address fields — see Collecting the billing address.
widget.on(event, callback)
widget.on(event, callback)Registers an event listener. See Events for the full list.
widget.getCheckoutData()
widget.getCheckoutData()Returns the current Checkout (or null). Most useful inside checkout-loaded.
widget.getSubscriptionOfferData()
widget.getSubscriptionOfferData()Returns the current SubscriptionOffer (or null). Most useful inside subscription-loaded.
widget.destroy()
widget.destroy()Tears down the widget and cleans up listeners. Call this when your component unmounts to avoid leaks.
Type reference
The SDK ships full TypeScript definitions. The types you'll touch most often:
import {
PaymentLabs,
PaymentWidget,
Checkout,
SubscriptionOffer,
SubscriptionPlan,
CheckoutErrorReason,
ThemeConfig,
} from "@paymentlabs/paymentlabs-js";Checkout
Returned by getCheckoutData().
interface Checkout {
id: string;
buyerEmail: string;
buyerId: string;
merchantId: string;
merchantName: string;
alias: string; // your reference / description
amount: { value: number; currency: string }; // value: 0 ⇒ save-card checkout
status: CheckoutStatus;
currentTransactionStatus?: CheckoutTransactionStatus;
errorTransaction?: {
transactionId: string;
errorReason: CheckoutErrorReason;
extendedErrorReason?: CheckoutExtendedErrorReason;
checkoutInstrumentId: string;
instrumentType: InstrumentType;
};
enabledCheckoutInstruments: InstrumentType[];
requiresFraudSession: boolean;
expiresAt: string; // ISO 8601
links: { embeddable: string; checkout: string };
redirectLinks?: {
successRedirectUrl?: string;
failureRedirectUrl?: string;
expiredRedirectUrl?: string;
};
termsOfServiceUrl: string;
privacyPolicyUrl: string;
}
type CheckoutStatus =
| "PENDING" // awaiting payment
| "PROCESSING" // payment in progress
| "PAID" // succeeded
| "FAILED" // failed
| "EXPIRED" // time window passed
| "CREATING" | "ACTIVE" | "TRIAL" | "UNKNOWN";SubscriptionOffer & SubscriptionPlan
interface SubscriptionOffer {
id: string;
merchantId: string;
userId: string;
subscriptionPlanIds: string[];
subscriptionPlans: SubscriptionPlan[];
expiresAt: string;
links: { embeddable: string; self: string };
}
interface SubscriptionPlan {
id: string;
merchantId: string;
merchantDisplayName: string;
sort: string; // ordering/grouping hint, e.g. "RegularPlan#100"
name: string;
description?: string;
enabledCheckoutInstruments: InstrumentType[];
schedule: Schedule;
amount: Money;
trialPeriod?: Schedule;
status: "ACTIVE" | "CANCELED";
termsOfServiceUrl?: string;
privacyPolicyUrl?: string;
}
interface Schedule {
interval: "daily" | "weekly" | "monthly" | "yearly";
intervalCount: number; // e.g. interval "monthly" + count 2 = every 2 months
}
interface Money {
value: number; // amount as a decimal value
currency: string; // ISO 4217
formattedValue: string; // e.g. "$9.99"
digits: number; // decimal digits for the currency
wholeValue: number; // value with no decimals (usually cents)
}ThemeConfig
interface ThemeConfig {
mode?: "dark" | "light"; // defaults to "light"
colors?: {
seed?: {
brand: string;
neutral: string;
info: string;
positive: string;
negative: string;
warning: string;
};
overrides?: {
"bg-main": string; // main background override
};
};
}BillingDetailsConfig & SubmitPaymentRequest
interface BillingDetailsConfig {
fields?: {
address?: boolean; // false ⇒ you provide the address at submit time
};
}
interface SubmitPaymentRequest {
billingDetails: {
address?: {
address1: string; // required
address2?: string;
city: string; // required
state: string; // required
postalCode: string; // required
country: string; // required, ISO 3166-1 alpha-2
};
};
}Going live
A short checklist before you flip environment to "production":
- Pin the SDK version if you load from the CDN.
- Create checkouts server-side — never trust an amount that came from the browser.
- Handle every outcome —
checkout-completed,checkout-failed, andcheckout-expiredshould each lead somewhere sensible. - Show progress — use
payment-processingto disable the button and signal work in flight. - Clean up — call
widget.destroy()when your component unmounts. - Test the edges — a decline, an expired checkout, and (if you use it) the zero-amount save-card flow.
Support
Stuck on something? Reach out to the Payment Labs support team or browse the rest of the API reference.
