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

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
  1. Your backend creates a checkout (the intent to collect money or save a card) and gets back a checkout ID.
  2. Your frontend hands that ID to the SDK, which renders the payment widget inside a container you choose.
  3. The buyer enters their details. The SDK tokenizes them securely (your servers never touch raw card data) and submits the transaction.
  4. 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 handlesYou handle
Secure card / bank / wallet input fieldsProduct pages, cart, and order summary
PCI-compliant tokenization & submissionPricing, tax, and shipping logic
Payment, save-card, and subscription flowsOrder creation and fulfillment
Real-time events and result screensThe "Pay" button and surrounding UI
Theming to match your brandCreating the checkout on your backend
📘

The golden rule

The 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:

  1. A Payment Labs account with a merchant ID (looks like C-220c070ec91944b8ad39814ec43d9c05).
  2. 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.

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-js

Prefer 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

latest is 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 tip

Since 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 fields

Option 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 address is false and you call submitPayment() without one, the call fails fast with: "Address information is required but not provided".

fields.addressWho renders the addressWhere the data comes from
true (default)The SDKThe widget's own fields
falseYouThe 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 fields

For 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

EventFires whenPayload
checkout-loadedThe form is rendered and ready
payment-processingSubmission starts/stopsboolean (true = busy)
checkout-processingThe charge is underway
checkout-completedPayment (or card save) succeeded
checkout-failedPayment failed{ errorReason, extendedErrorReason? }
checkout-expiredThe checkout's time window passed
payment-failedA pre-charge step failed (e.g. validation)

Subscription events

EventFires whenPayload
subscription-loadedThe offer is rendered and ready
subscription-plan-changedThe buyer selects a different planSubscriptionPlan
subscription-processingThe subscription is being set up
payment-processingSubmission starts/stopsboolean
subscription-completedThe subscription was created
subscription-failedSubscription setup failed{ errorReason, extendedErrorReason? }
subscription-expiredThe 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:

ValueMeaning
MISSING_INSTRUMENTNo payment method was provided
INVALID_INSTRUMENTThe payment method was invalid
CHARGE_INSTRUMENT_ERRORThe instrument couldn't be charged (e.g. declined)
APPLICATION_ERRORAn 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)

Creates an SDK instance. Call it once and reuse it.

const sdk = PaymentLabs.create({
  environment: "sandbox", // "sandbox" | "production"
  merchantId: "C-220c070ec91944b8ad39814ec43d9c05",
});
FieldTypeDescription
environment"sandbox" | "production"Which Payment Labs environment to talk to
merchantIdstringYour merchant identifier

Returns a PaymentLabs instance.

sdk.createWidget(type, id, options)

Creates and mounts a payment widget. Returns Promise<PaymentWidget>.

ParameterTypeDescription
type"checkout" | "subscription"The kind of widget to render
idstringA checkout ID (for "checkout") or plan offer ID (for "subscription")
optionsWidgetOptionsSee 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)

Switches the widget between "light" and "dark". ⚠️ Re-renders secure card fields (see Theming).

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)

Registers an event listener. See Events for the full list.

widget.getCheckoutData()

Returns the current Checkout (or null). Most useful inside checkout-loaded.

widget.getSubscriptionOfferData()

Returns the current SubscriptionOffer (or null). Most useful inside subscription-loaded.

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":

  1. Pin the SDK version if you load from the CDN.
  2. Create checkouts server-side — never trust an amount that came from the browser.
  3. Handle every outcomecheckout-completed, checkout-failed, and checkout-expired should each lead somewhere sensible.
  4. Show progress — use payment-processing to disable the button and signal work in flight.
  5. Clean up — call widget.destroy() when your component unmounts.
  6. 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.