# Welcome (/docs) Three products cover everything Easy Labs ships today. ## Products [#products] ## Build with your stack [#build-with-your-stack] ## API [#api] # Billing compliance & scale (/docs/billing/compliance) {/* TODO: Single-page summary. For now, link to the shared compliance page: */} See the cross-product [Reference → Compliance](/docs/reference/compliance) page for security model, PCI scope, certifications, uptime, throughput limits, and data residency. # Billing (/docs/billing) Run subscriptions, send invoices, configure dunning + a self-serve customer portal. ## Get started [#get-started] ## Common patterns [#common-patterns] * [Create a subscription](/docs/billing/guides/create-subscription) — start a recurring charge against a saved instrument, with optional trial and proration. * [Send an invoice](/docs/billing/guides/send-invoice) — issue an ad-hoc invoice and let the customer pay through a hosted page. * [Launch the customer portal](/docs/billing/guides/launch-customer-portal) — magic-link a customer into a hosted page where they self-serve plan changes, cancellations, and payment methods. * [Configure dunning](/docs/billing/guides/configure-dunning) — set retry policy and recovery emails so failed renewals self-heal. ## Migrating from another provider [#migrating-from-another-provider] * [From Stripe](/docs/billing/migration/from-stripe) — side-by-side method, event, and object-model mapping. # Billing quickstart (/docs/billing/quickstart) This walks the shortest path: install `@easylabs/node`, create a Product + Price, attach a Customer, and start a Subscription. The end state is an `active` subscription that will bill on its own going forward. ## 1. Get an API key [#1-get-an-api-key] Sign in to the [Easy Labs dashboard](https://dashboard.itseasy.co), go to **Developers → API keys**, and create a sandbox key (`sk_sandbox_...`). Sandbox keys hit a fully isolated test environment — real money never moves. Switch to a production key (`sk_live_...`) when you are ready to go live; the SDK routes based on the key prefix. ## 2. Install the SDK [#2-install-the-sdk] ```bash npm install @easylabs/node # pnpm add @easylabs/node # yarn add @easylabs/node ``` Other languages: see [SDKs](/docs/sdks). ## 3. Create a product, price, and subscription [#3-create-a-product-price-and-subscription] This is server-side. It creates the catalog entry, attaches a recurring price, creates a customer, and starts a subscription against an existing payment instrument. ```ts import { createClient } from "@easylabs/node"; const easy = await createClient({ apiKey: process.env.EASY_API_KEY! }); // 1. Catalog: a product and one recurring USD price ($29.00 / month). const { data: product } = await easy.createProduct({ name: "Pro plan", description: "Everything in Free, plus advanced analytics.", active: true, }); const { data: price } = await easy.createPrice({ product_id: product.id, active: true, recurring: true, currency: "USD", unit_amount: 2900, interval: "month", interval_count: 1, tax_behavior: "exclusive", }); // 2. Customer + payment instrument. const { data: customer } = await easy.createCustomer({ first_name: "Ada", last_name: "Lovelace", email: "ada@example.com", }); // In a real flow you'd collect card details client-side and pass the // resulting tokenId here. See /docs/payments/concepts/payment-instrument. const { data: instrument } = await easy.createPaymentInstrument({ type: "PAYMENT_CARD", identityId: customer.id, name: "Ada Lovelace", tokenId: process.env.SANDBOX_CARD_TOKEN!, address: { line1: "1 Test St", city: "SF", region: "CA", postal_code: "94105", country: "USA" }, }); // 3. Subscription: charges the instrument now and on each renewal. const { data: subscription } = await easy.createSubscription({ identity_id: customer.id, instrument_id: instrument.id, items: [{ price_id: price.id, quantity: 1 }], }); console.log(subscription.id, subscription.status); // sub_..., "active" ``` ## 4. Verify [#4-verify] Read it back to confirm the subscription is `active` and the first invoice was generated: ```ts const { data } = await easy.getSubscription(subscription.id); console.log(data.status, data.latest_invoice_id, data.current_period_end); ``` Or open the subscription in the **Billing → Subscriptions** view in the dashboard. The matching `subscription.created` and `invoice.paid` events will fire to any [registered webhook](/docs/reference/webhooks). ## 5. Next steps [#5-next-steps] * [Create a subscription](./guides/create-subscription) — full version with trials, proration, and item changes * [Send an invoice](./guides/send-invoice) — for one-off charges and quote-style billing * [Configure dunning](./guides/configure-dunning) — retry + recovery on failed renewals * [Launch the customer portal](./guides/launch-customer-portal) — let customers self-serve # API changelog (/docs/changelog/api) The Easy API doesn't ship a `CHANGELOG.md` yet. Once `easy-api` adds one (or its release tags get published), this page will sync from it automatically — see the changelog sync target in `easy-docs/scripts/sync-content.ts`. In the meantime, surface-level changes are reflected in the [API reference](/docs/api), which is regenerated on every staging deploy from the live `/openapi.json`. # Changelog (/docs/changelog) Each SDK ships its own version history. We use [Changesets](https://github.com/changesets/changesets) on the TypeScript packages and per-package versioning on the Ruby gem and Python pip package, so every change lands in its repo's `CHANGELOG.md` and syncs into this section. ## Frontend SDKs [#frontend-sdks] ## Backend SDKs [#backend-sdks] ## API [#api] # JavaScript SDK changelog (/docs/changelog/javascript) Synced from [`packages/easy-browser/CHANGELOG.md`](https://github.com/itseasyco/easy-sdk/blob/main/packages/easy-browser/CHANGELOG.md) on the latest publish. This is the canonical release history for `@easylabs/browser`. ## 0.1.0 [#010] ### Minor Changes [#minor-changes] Initial release of the framework-agnostic browser SDK. Mirrors Stripe's `@stripe/stripe-js` shape so consumers on Vue, Angular, Svelte, HTMX, Astro, and vanilla JS can use Easy Labs without React. * `createEasyClient({ apiKey, __dev?, __internal_api_url? })` — async factory that validates the API key and returns the full `EasyApiClient` surface plus browser-specific helpers. * `mountEmbeddedCheckout(container, options)` — vanilla DOM helper that mounts the Easy Labs embedded-checkout iframe, wires the postMessage handshake (resize, ready, crypto\_confirmed), and returns a handle with `update(clientSecret)` / `unmount()` methods. Functional equivalent of the React `` component. * Re-exports `EasyApiClient`, `EasyApiError`, and every public type from `@easylabs/common` so consumers don't need a second import. # Node.js SDK changelog (/docs/changelog/node) Synced from [`packages/easy-node/CHANGELOG.md`](https://github.com/itseasyco/easy-sdk/blob/main/packages/easy-node/CHANGELOG.md) on the latest publish. This is the canonical release history for `@easylabs/node`. ## Unreleased [#unreleased] ### BREAKING CHANGES [#breaking-changes] * Removed balance transfer operations from the SDK surface. * Migration: Treasury operations are managed internally by Easy Labs. ## 0.0.10 [#0010] ### Patch Changes [#patch-changes] * 7134a80: Update ProductData for new api contract returning price ids array ## 0.0.9 [#009] ### Patch Changes [#patch-changes-1] * Added comprehensive subscription management capabilities including subscription plans and customer subscriptions: **Subscription Plans:** * `getSubscriptionPlans(params?)` - List all subscription plans with optional filtering * `getSubscriptionPlan(planId)` - Get a specific subscription plan * `createSubscriptionPlan(data)` - Create a new subscription plan with: * Billing intervals (DAILY, WEEKLY, MONTHLY, QUARTERLY, YEARLY) * Trial periods with configurable duration * Discount phases for promotional pricing * Billing defaults (invoice/receipt settings) * `updateSubscriptionPlan(planId, data)` - Update an existing plan **Customer Subscriptions:** * `getSubscriptions(params?)` - List all subscriptions (admin context) * `getCustomerSubscriptions(params?)` - Get subscriptions for authenticated customer * `getSubscription(subscriptionId)` - Get a specific subscription * `createSubscription(data)` - Create a subscription with: * Buyer details (name, email, phone) * Subscription details (trial overrides, discount overrides) * Payment instrument configuration * `cancelSubscription(subscriptionId)` - Cancel an active subscription **Integration:** * Recurring prices now automatically create subscriptions during checkout * Mixed cart support (one-time purchases + subscriptions in single transaction) * Subscription data included in order line items for tracking All list/collection endpoints now support filtering by specific IDs using the `ids` query parameter: **Affected Methods:** * `getCustomers({ ids: ['cus_123', 'cus_456'] })` * `getProducts({ ids: ['prod_123', 'prod_456'] })` * `getPrices({ ids: ['price_123', 'price_456'] })` * `getOrders({ ids: ['ord_123', 'ord_456'] })` * `getTransfers({ ids: ['tfr_123', 'tfr_456'] })` * `getSettlements({ ids: ['set_123', 'set_456'] })` * `getDisputes({ ids: ['dis_123', 'dis_456'] })` * `getSubscriptions({ ids: ['sub_123', 'sub_456'] })` * `getSubscriptionPlans({ ids: ['plan_123', 'plan_456'] })` This enables efficient batch fetching of specific resources without retrieving entire collections. **Updated Return Value:** The `checkout()` and related checkout methods now return enhanced data: ```typescript { order: OrderData; // The created order customer: CustomerData; // Customer information subscriptions?: SubscriptionData[]; // Auto-created subscriptions (if cart contains recurring items) } ``` **Benefits:** * Immediate access to created subscription IDs * No need for separate API calls to fetch subscription details * Simplified post-checkout subscription management * Better support for mixed carts (one-time + recurring) * Added comprehensive subscription examples to Node.js and React SDK docs * Updated DEVELOPER\_DOCS.md for both packages * Enhanced Docusaurus site with subscription management guides * Added batch query parameter documentation across all endpoints **Checkout Return Value:** The checkout methods now return an object with `{ order, customer, subscriptions? }` instead of just the order. Update your code: ```typescript // Before const order = await checkout(data); // After const { order, customer, subscriptions } = await checkout(data); ``` * Fixed price display for subscription products (now uses subscription\_plan.amount) * Improved type safety for discriminated union types (CreatePrice, CreateSubscription) * Fixed controlled input warnings in form components ### Patch Changes [#patch-changes-2] * * Add handlers for getting a customers payment instruments and orders * Add handler for updating payment instrument * Add handlers for getting order(s) and updating order tags * Add handlers for getProductWithPrice(s). This is used for either getting all the prices for a product or a specifc one. Different from returning the default\_price\_id ## 0.0.8 [#008] ### Patch Changes [#patch-changes-3] * Create `EasyApiClient` class to handle all the interactions with easy-api, this reduced a lot of repetitive code that was happening between the node and react packages * Add product and price management APIs ## 0.0.7 [#007] ### Patch Changes [#patch-changes-4] * Update amount handling and type references * Add customer and transfer retrieval/update methods ## 0.0.6 [#006] ### Patch Changes [#patch-changes-5] * Refactor types package into common and update configs ## 0.0.5 [#005] ### Patch Changes [#patch-changes-6] * Add environment-based API URL selection * Add tags support to customer, instrument, and transfer forms + refactor types ## 0.0.4 [#004] ### Patch Changes [#patch-changes-7] * Update package to ES module ## 0.0.3 [#003] ### Patch Changes [#patch-changes-8] * Move workspace pacakge to devDependency ## 0.0.2 [#002] ### Patch Changes [#patch-changes-9] * Add tsdown bundler ## 0.0.1 [#001] ### Patch Changes [#patch-changes-10] * Update to use new shared common package and fix api endpoints # Python SDK changelog (/docs/changelog/python) Synced from [`CHANGELOG.md`](https://github.com/itseasyco/easy-sdk-python/blob/main/CHANGELOG.md) on the latest publish. This is the canonical release history for `easylabs (pip package)`. ## 0.1.1 [#011] ### Removed [#removed] * The `dev=True` keyword argument on `Client(...)` and the matching `EASY_API_URL_DEV` constant in `easylabs._api_url`. The internal Easy Labs dev environment URL is no longer baked into the public package. Internal callers who genuinely need to point at a non-public host should pass `internal_api_url="https://..."` to `Client(...)`. Migration: replace `Client(api_key=..., dev=True)` with `Client(api_key=..., internal_api_url="https://...")` if you were using it. The standard `sk_test_` → sandbox / `sk_live_` → production routing is unchanged. ## 0.1.0 [#010] First published release of the Python SDK, mirroring `@easylabs/node@0.1.0`. ### Added [#added] * `easylabs.Client` — instance-style API client (mirrors `createClient` in the Node SDK) with namespaced resources (`client.customers.create(...)`). * HTTP transport built on `httpx`, supporting query encoding, JSON bodies, optional `Idempotency-Key` header, and `skipAuth` for embedded-checkout public endpoints (`validate` / `confirm`). * Sandbox / production URL routing — keys prefixed `sk_test_` route to `https://sandbox-api.itseasy.co/v1/api`; everything else routes to `https://api.itseasy.co/v1/api`. An `internal_api_url=` override is available for tests and self-hosted setups. * Full resource surface (\~80 methods across 21 namespaces): customers, payment instruments, transfers (incl. refunds), disputes, settlements, products, product prices, orders, subscriptions (incl. items, discounts, usage, one-time charges, proration preview, pause/resume), checkout, payment links, embedded checkout (incl. config + crypto status), webhook endpoints (incl. delivery listings), invoices, coupons, promotion codes, authorizations, analytics, compliance forms, dunning config, and revenue recovery automations. * `easylabs.Webhooks.construct_event(...)` — verifies HMAC-SHA256 webhook signatures with `hmac.compare_digest` and parses the payload into a typed `WebhookEvent` Pydantic model. * `EASY_EVENT_TYPES` constant — full catalog of webhook event types. * Error class hierarchy under `easylabs.error.EasyError` with HTTP-status subclasses (`AuthenticationError`, `PermissionError`, `NotFoundError`, `ConflictError`, `RateLimitError`, `InvalidRequestError`, `ServerError`) plus `status`, `code`, `details`, `retry_after_seconds`, and `raw` fields. * Pydantic v2 response models with `extra="allow"` for forward compatibility. # React Native SDK changelog (/docs/changelog/react-native) Synced from [`packages/easy-react-native/CHANGELOG.md`](https://github.com/itseasyco/easy-sdk/blob/main/packages/easy-react-native/CHANGELOG.md) on the latest publish. This is the canonical release history for `@easylabs/react-native`. ## Unreleased [#unreleased] ### BREAKING CHANGES [#breaking-changes] * Removed balance transfer operations from the SDK surface. * Migration: Treasury operations are managed internally by Easy Labs. ## 0.0.1 [#001] ### Patch Changes [#patch-changes] * 7134a80: Update ProductData for new api contract returning price ids array # React SDK changelog (/docs/changelog/react) Synced from [`packages/easy-react/CHANGELOG.md`](https://github.com/itseasyco/easy-sdk/blob/main/packages/easy-react/CHANGELOG.md) on the latest publish. This is the canonical release history for `@easylabs/react`. ## Unreleased [#unreleased] ### BREAKING CHANGES [#breaking-changes] * Removed balance transfer operations from the SDK surface. * Migration: Treasury operations are managed internally by Easy Labs. ## 0.0.10 [#0010] ### Patch Changes [#patch-changes] * 7134a80: Update ProductData for new api contract returning price ids array ## 0.0.9 [#009] ### Patch Changes [#patch-changes-1] * Added comprehensive subscription management capabilities including subscription plans and customer subscriptions: **Subscription Plans:** * `getSubscriptionPlans(params?)` - List all subscription plans with optional filtering * `getSubscriptionPlan(planId)` - Get a specific subscription plan * `createSubscriptionPlan(data)` - Create a new subscription plan with: * Billing intervals (DAILY, WEEKLY, MONTHLY, QUARTERLY, YEARLY) * Trial periods with configurable duration * Discount phases for promotional pricing * Billing defaults (invoice/receipt settings) * `updateSubscriptionPlan(planId, data)` - Update an existing plan **Customer Subscriptions:** * `getSubscriptions(params?)` - List all subscriptions (admin context) * `getCustomerSubscriptions(params?)` - Get subscriptions for authenticated customer * `getSubscription(subscriptionId)` - Get a specific subscription * `createSubscription(data)` - Create a subscription with: * Buyer details (name, email, phone) * Subscription details (trial overrides, discount overrides) * Payment instrument configuration * `cancelSubscription(subscriptionId)` - Cancel an active subscription **Integration:** * Recurring prices now automatically create subscriptions during checkout * Mixed cart support (one-time purchases + subscriptions in single transaction) * Subscription data included in order line items for tracking All list/collection endpoints now support filtering by specific IDs using the `ids` query parameter: **Affected Methods:** * `getCustomers({ ids: ['cus_123', 'cus_456'] })` * `getProducts({ ids: ['prod_123', 'prod_456'] })` * `getPrices({ ids: ['price_123', 'price_456'] })` * `getOrders({ ids: ['ord_123', 'ord_456'] })` * `getTransfers({ ids: ['tfr_123', 'tfr_456'] })` * `getSettlements({ ids: ['set_123', 'set_456'] })` * `getDisputes({ ids: ['dis_123', 'dis_456'] })` * `getSubscriptions({ ids: ['sub_123', 'sub_456'] })` * `getSubscriptionPlans({ ids: ['plan_123', 'plan_456'] })` This enables efficient batch fetching of specific resources without retrieving entire collections. **Updated Return Value:** The `checkout()` and related checkout methods now return enhanced data: ```typescript { order: OrderData; // The created order customer: CustomerData; // Customer information subscriptions?: SubscriptionData[]; // Auto-created subscriptions (if cart contains recurring items) } ``` **Benefits:** * Immediate access to created subscription IDs * No need for separate API calls to fetch subscription details * Simplified post-checkout subscription management * Better support for mixed carts (one-time + recurring) * Added comprehensive subscription examples to Node.js and React SDK docs * Updated DEVELOPER\_DOCS.md for both packages * Enhanced Docusaurus site with subscription management guides * Added batch query parameter documentation across all endpoints **Checkout Return Value:** The checkout methods now return an object with `{ order, customer, subscriptions? }` instead of just the order. Update your code: ```typescript // Before const order = await checkout(data); // After const { order, customer, subscriptions } = await checkout(data); ``` * Fixed price display for subscription products (now uses subscription\_plan.amount) * Improved type safety for discriminated union types (CreatePrice, CreateSubscription) * Fixed controlled input warnings in form components ### Patch Changes [#patch-changes-2] * Introduces a new `checkout` method to the EasyProvider context, consolidating and generalizing the checkout logic for both new and existing customers. * * Add handlers for getting a customers payment instruments and orders * Add handler for updating payment instrument * Add handlers for getting order(s) and updating order tags * Add handlers for getProductWithPrice(s). This is used for either getting all the prices for a product or a specifc one. Different from returning the default\_price\_id ## 0.0.8 [#008] ### Patch Changes [#patch-changes-3] * Create `EasyApiClient` class to handle all the interactions with easy-api, this reduced a lot of repetitive code that was happening between the node and react packages * Add product and price management APIs * Fix type handling for payment instrument params * Changed anonCheckout & checkoutExistingCustomer to only return the id's of paymentInstrument & transfer instead of the entire data object ## 0.0.7 [#007] ### Patch Changes [#patch-changes-4] * Support separate card input elements in EasyProvider ## 0.0.6 [#006] ### Patch Changes [#patch-changes-5] * Update amount handling and type references ## 0.0.5 [#005] ### Patch Changes [#patch-changes-6] * Refactor types package into common and update configs ## 0.0.4 [#004] ### Patch Changes [#patch-changes-7] * Switch easy-react to tsdown build system ## 0.0.3 [#003] ### Patch Changes [#patch-changes-8] * Add environment-based API URL selection * Add tags support to customer, instrument, and transfer forms + refactor types * Expose tokenizePaymentInstrument from EasyContext ## 0.0.2 [#002] ### Patch Changes [#patch-changes-9] * Fixed API endpoints and andded updateCustomer method ## 0.0.1 [#001] ### Patch Changes [#patch-changes-10] * initial release # Ruby SDK changelog (/docs/changelog/ruby) Synced from [`CHANGELOG.md`](https://github.com/itseasyco/easy-sdk-ruby/blob/main/CHANGELOG.md) on the latest publish. This is the canonical release history for `easy-sdk (Ruby gem)`. All notable changes to `easy-sdk` are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/). ## [0.1.0] — Unreleased [#010--unreleased] Initial release of the Ruby SDK. Mirrors `@easylabs/node` 0.1.0 feature surface with idiomatic Ruby APIs. ### Added [#added] * `EasyLabs::Client.new(api_key:)` factory that validates the API key on construction and exposes resource namespaces (`client.customers`, `client.subscriptions`, etc.). * Full resource coverage: customers, payment instruments, transfers (incl. refunds), disputes, settlements, products, product prices, orders, subscriptions (full lifecycle: pause/resume, items, discounts, one-time charges, metered usage, proration preview), checkout, payment links, embedded checkout (incl. validate/confirm/config), webhook management, invoices, coupons, promotion codes, authorizations, analytics, compliance forms, dunning config, revenue-recovery automations. * `EasyLabs::Webhooks.construct_event` HMAC-SHA256 verifier with the full `EVENT_TYPES` catalog. * Typed exception hierarchy under `EasyLabs::Error` (AuthenticationError, PermissionError, NotFoundError, ConflictError, RateLimitError, InvalidRequestError, ServerError) with `status`, `code`, `details`, `retry_after_seconds`, and `raw` accessors. * Sandbox auto-routing — `sk_test_*` keys hit `sandbox-api.itseasy.co` automatically. [0.1.0]: https://github.com/itseasyco/easy-sdk-ruby/releases/tag/v0.1.0 # Payments compliance & scale (/docs/payments/compliance) {/* TODO: Single-page summary. For now, link to the shared compliance page: */} See the cross-product [Reference → Compliance](/docs/reference/compliance) page for security model, PCI scope, certifications, uptime, throughput limits, and data residency. # Payments (/docs/payments) Accept one-time payments, embed checkout, manage payment instruments + disputes. ## Get started [#get-started] ## Common patterns [#common-patterns] * [Embed hosted checkout](/docs/payments/guides/embed-hosted-checkout) — drop the iframe in for first-time card payments without taking on PCI scope. * [Accept a card payment](/docs/payments/guides/accept-card-payment) — server-to-server charge against a saved Payment Instrument. * [Accept a wallet payment](/docs/payments/guides/accept-wallet-payment) — opt the iframe into Solana / USDC checkout for the same session. * [Handle a dispute](/docs/payments/guides/handle-dispute) — react to chargeback webhooks and correlate them back to your Order. ## Migrating from another provider [#migrating-from-another-provider] * [Migrate from Stripe](/docs/payments/migration/from-stripe) — method-by-method and event-by-event mapping. # Payments quickstart (/docs/payments/quickstart) This walks the shortest path: install `@easylabs/node`, create a Customer, mount an Embedded Checkout session, and verify the resulting Order. End-to-end this is a few minutes of typing. ## 1. Get an API key [#1-get-an-api-key] Sign in to the [Easy Labs dashboard](https://dashboard.itseasy.co), go to **Developers → API keys**, and create a sandbox key (`sk_sandbox_...`). Sandbox keys hit a fully isolated test environment — real money never moves. Switch to a production key (`sk_live_...`) once you are ready to go live; the SDK auto-routes based on the key prefix. ## 2. Install the SDK [#2-install-the-sdk] ```bash npm install @easylabs/node # pnpm add @easylabs/node # yarn add @easylabs/node ``` For browser flows install one of: ```bash npm install @easylabs/react # React apps npm install @easylabs/browser # vanilla JS ``` Other languages: see [SDKs](/docs/sdks). ## 3. Create a Customer and start a checkout session [#3-create-a-customer-and-start-a-checkout-session] This snippet runs on your server. It creates a Customer and an Embedded Checkout session that you can hand to the browser. ```ts import { createClient } from "@easylabs/node"; const easy = await createClient({ apiKey: process.env.EASY_API_KEY! }); // 1. Create the buyer. const { data: customer } = await easy.createCustomer({ first_name: "Ada", last_name: "Lovelace", email: "ada@example.com", }); // 2. Start an embedded checkout session for one product price. const { data: session } = await easy.createEmbeddedCheckoutSession({ mode: "payment", customer_email: customer.entity.email, line_items: [{ price_id: "price_01HXXXXXXXXXXX", quantity: 1 }], success_url: "https://your-app.com/checkout/success", cancel_url: "https://your-app.com/checkout/cancel", payment_methods: ["card"], }); // Send `session.client_secret` to the browser. Do NOT send the API key. return Response.json({ clientSecret: session.client_secret }); ``` On the browser, render the iframe. With React: ```tsx import { EmbeddedCheckout, EmbeddedCheckoutProvider } from "@easylabs/react"; export function Checkout({ clientSecret }: { clientSecret: string }) { return ( console.log("paid", sessionId), }} > ); } ``` ## 4. Verify [#4-verify] After the buyer completes the iframe, you can confirm server-side: ```ts const { data: status } = await easy.getEmbeddedCheckoutSession(session.id); console.log(status.status, status.payment_status); // → "complete" "paid" ``` Or open the [dashboard](https://dashboard.itseasy.co) and look for the new Order under **Payments → Orders**. ## 5. Next steps [#5-next-steps] * [Accept a card payment](./guides/accept-card-payment) — direct server-to-server charge against a saved Payment Instrument. * [Embed hosted checkout](./guides/embed-hosted-checkout) — production-grade embed with success / cancel / error handling. * [Issue a refund](./guides/issue-refund) — partial and full reversals. * [Migrate from Stripe](./migration/from-stripe) — method-by-method mapping. # Compliance & scale (/docs/reference/compliance) {/* TODO: Cross-product compliance summary. Should cover: - PCI DSS scope (Easy holds PCI; merchants offload via Elements/Embedded) - SOC 2 / ISO 27001 status (or roadmap) - Encryption at rest + in transit - Data residency - Uptime SLA (link to status page) - Rate limits - Webhooks delivery guarantees */} # Webhooks (/docs/reference/webhooks) Easy Labs delivers asynchronous notifications about account activity by `POST`-ing JSON to an HTTPS endpoint you register. Use webhooks to react to payments completing, subscriptions renewing, disputes opening, or settlements landing — without polling. ## Quickstart [#quickstart] 1. Register an endpoint via the [`webhooks` API](/docs/reference/api/account-and-operations/webhooks) or the dashboard. You'll get a one-time `secret` — store it immediately, it's never re-exposed. 2. Receive deliveries at your URL — Easy Labs `POST`s a signed JSON payload. 3. Verify the signature with [`EasyWebhooks.constructEvent`](#verifying-deliveries) before trusting the payload. 4. Respond `2xx` within 30 seconds. Anything else triggers a retry. ```ts import express from 'express'; import { EasyWebhooks } from '@easylabs/node'; const app = express(); // IMPORTANT: use raw body — JSON re-stringifying breaks the signature. app.post( '/webhooks/easy', express.raw({ type: 'application/json' }), (req, res) => { const event = EasyWebhooks.constructEvent( req.body.toString('utf8'), req.header('x-easy-webhook-signature') ?? '', process.env.EASY_WEBHOOK_SECRET!, ); switch (event.type) { case 'payment.created': // ... break; case 'subscription.updated': // ... break; } res.status(204).end(); }, ); ``` ## Delivery format [#delivery-format] Each delivery is an HTTPS `POST` to your registered endpoint URL with these headers: | Header | Value | | -------------------------- | --------------------------------------------------------------------------------- | | `content-type` | `application/json` | | `x-easy-webhook-signature` | `sha256=` — HMAC-SHA256 of the raw body using your endpoint's signing secret | | `x-easy-event` | The event type, e.g. `payment.created` | | `x-easy-delivery-id` | UUID identifying this specific delivery attempt | | `x-easy-webhook-attempt` | Attempt number, `"1"` through `"3"` | The body is a JSON `WebhookEvent`: ```json { "id": "evt_01HABCDEFGHIJK", "type": "payment.created", "created_at": "2026-05-03T12:34:56.789Z", "created": "2026-05-03T12:34:56.789Z", "api_version": "2026-05-01", "data": { /* event-specific payload */ }, "previous_attributes": { /* present on `*.updated` events */ }, "requested": { "id": "req_01HXYZ...", "idempotency_key": "your-key" } } ``` ## Verifying deliveries [#verifying-deliveries] The signing secret returned at registration is your shared key. Verify every delivery before acting on it — anyone who knows your endpoint URL can `POST` to it. ### Node.js [#nodejs] ```ts import { EasyWebhooks } from '@easylabs/node'; const event = EasyWebhooks.constructEvent( rawBody, // exact request body string req.header('x-easy-webhook-signature')!, // sha256= process.env.EASY_WEBHOOK_SECRET!, ); ``` `constructEvent` throws an `EasyApiError` (status 400) on any of: * Missing or malformed signature header * Signature is not valid hex * Signature does not match the computed HMAC (timing-safe comparison) * Body is not valid JSON On success it returns a typed `WebhookEvent`. ### Other languages [#other-languages] The verification recipe is identical: `HMAC-SHA256(body, secret)`, hex-encoded, prefixed with `sha256=`, compared in constant time. ```python # Python — manual verification (SDK helper coming soon) import hashlib, hmac expected = 'sha256=' + hmac.new( secret.encode('utf-8'), raw_body.encode('utf-8'), hashlib.sha256, ).hexdigest() if not hmac.compare_digest(expected, signature_header): raise ValueError('Invalid webhook signature') ``` ```ruby # Ruby — manual verification (SDK helper coming soon) require 'openssl' expected = 'sha256=' + OpenSSL::HMAC.hexdigest('SHA256', secret, raw_body) unless Rack::Utils.secure_compare(expected, signature_header) raise 'Invalid webhook signature' end ``` Native `EasyWebhooks` helpers for Ruby and Python ship with a future SDK round — until then, use the manual verification above. ### Replay protection [#replay-protection] The current signature covers the body only — there is **no signed timestamp**. If you receive the same `x-easy-delivery-id` twice (because Easy Labs retried after a network blip and your server actually succeeded), idempotency is your responsibility: dedupe on `event.id` or `x-easy-delivery-id`. A signed timestamp + `tolerance:` parameter is on the [SDK gap-fix tracker](https://github.com/itseasyco/easy-sdk/blob/main/docs/sdk-gap-fix-tracker.md) (item #2). When it ships, `EasyWebhooks.constructEvent` will accept a `tolerance` argument and reject deliveries older than the window. ## Retry behavior [#retry-behavior] Easy Labs retries any delivery that doesn't return a `2xx` within 30 seconds. The dispatcher attempts each delivery up to **3 times** before giving up, with exponential backoff between attempts. The attempt count is exposed on every delivery via `x-easy-webhook-attempt`. If your endpoint fails enough consecutive deliveries, the endpoint's `consecutive_failures` counter increments and Easy Labs eventually marks it `disabled`. Re-enable it via the dashboard or the [update endpoint API](/docs/reference/api/account-and-operations/webhooks). To replay a delivery on demand, find it in the dashboard's webhook log and click "Resend" — useful for testing handlers without waiting for real activity. ## Event types [#event-types] Subscribe to specific events when registering an endpoint, or use `["*"]` to catch everything. The full catalog (37 event types as of `api_version 2026-05-01`): ### Payments [#payments] * `payment.created` — a new payment is initiated * `payment.updated` — payment status changed (succeeded, failed, refunded, etc.) * `refund.created` — a refund is initiated * `refund.updated` — refund status changed * `authorization.created` — an authorization is created (manual capture flow) * `authorization.updated` — authorization status changed * `authorization.voided` — authorization explicitly voided * `dispute.created` — a chargeback or pre-dispute opens * `dispute.updated` — dispute moves through its lifecycle (under-review, won, lost, etc.) * `checkout.session.completed` — a hosted/embedded Checkout session closes successfully * `checkout.session.crypto_confirmed` — a crypto checkout confirms on-chain ### Billing [#billing] * `subscription.created` — a new subscription is created * `subscription.updated` — subscription fields change (plan, quantity, billing cycle, etc.) * `subscription.deleted` — subscription canceled * `subscription.paused` — subscription paused (collection paused or fully halted) * `subscription.resumed` — subscription resumed * `subscription.trial_will_end` — fires 3 days before a trial ends * `subscription.pending_update_applied` — a scheduled update took effect * `subscription.pending_update_expired` — a scheduled update expired before applying * `invoice.created` — a draft invoice is created * `invoice.finalized` — invoice is finalized and ready to send/charge * `invoice.paid` — invoice fully paid * `invoice.payment_failed` — payment attempt failed (triggers dunning) * `invoice.upcoming` — fires before the next renewal so you can preview the invoice * `invoice.voided` — invoice voided * `invoice.marked_uncollectible` — invoice flagged unrecoverable * `revenue_recovery.action_completed` — a dunning recovery step fired (retry, email, etc.) * `coupon.created` / `coupon.updated` / `coupon.deleted` * `promotion_code.created` / `promotion_code.updated` / `promotion_code.deleted` ### Treasury [#treasury] * `settlement.created` — a settlement batch lands ### Account [#account] * `identity.created` — KYB / merchant identity record created * `identity.updated` — KYB / merchant identity record changed (incl. status transitions) ### Test [#test] * `test.webhook` — fires when you click "Send test event" in the dashboard ## Endpoint management [#endpoint-management] Webhook endpoints are managed under the [account-and-operations API](/docs/reference/api/account-and-operations/webhooks): * `POST /webhooks` — register a new endpoint (returns the signing `secret` once) * `GET /webhooks` — list endpoints * `GET /webhooks/{id}` — get a single endpoint (no secret in response) * `PATCH /webhooks/{id}` — update URL, events, or active status * `DELETE /webhooks/{id}` — remove an endpoint Each endpoint exposes: ```ts interface WebhookEndpoint { id: string; url: string; events: string[]; // e.g. ["payment.created", "*"] active: boolean; status: 'enabled' | 'disabled'; consecutive_failures: number; last_triggered_at: string | null; created_at: string; updated_at: string; } ``` ## Inspecting deliveries [#inspecting-deliveries] Every delivery attempt is logged. Query the delivery log to debug failures or replay events: ```ts const deliveries = await client.listWebhookDeliveries({ endpoint_id: 'whe_01HABCD...', success: false, created_after: '2026-05-01T00:00:00Z', limit: 50, }); ``` The dashboard surfaces the same data with one-click "Resend" — useful for replaying without writing code. ## Related [#related] * [Webhooks API](/docs/reference/api/account-and-operations/webhooks) — endpoint management operations * [Node SDK reference](/docs/sdks/node) — `EasyWebhooks.constructEvent` and the `WebhookEvent` type * [SDK gap-fix tracker #2](https://github.com/itseasyco/easy-sdk/blob/main/docs/sdk-gap-fix-tracker.md) — signed timestamp + replay protection roadmap # SDKs (/docs/sdks) # Treasury compliance & scale (/docs/treasury/compliance) {/* TODO: Single-page summary. For now, link to the shared compliance page: */} See the cross-product [Reference → Compliance](/docs/reference/compliance) page for security model, PCI scope, certifications, uptime, throughput limits, and data residency. # Treasury (/docs/treasury) Send payouts to bank accounts, manage recipients, generate payout links + tax documents. ## Get started [#get-started] ## Common patterns [#common-patterns] * [Send a payout](/docs/treasury/guides/send-payout) — debit a funding account and credit a recipient over ACH, RTP, or wire. * [Invite a recipient](/docs/treasury/guides/invite-recipient) — let recipients enter their own banking via a 72-hour self-serve link instead of collecting account numbers yourself. * [Generate a payout link](/docs/treasury/guides/generate-payout-link) — share a single-use, fixed-amount link with someone who isn't a recipient yet. * [Request a W-9](/docs/treasury/guides/request-w9) — capture a US recipient's tax info ahead of year-end 1099 issuance. # Treasury quickstart (/docs/treasury/quickstart) This walks the smallest end-to-end happy path: create a recipient with a bank account attached, then send them a payout. Server-side, in Node. ## 1. Get an API key [#1-get-an-api-key] In the [Easy Dashboard](https://app.itseasy.co), open **Settings → API keys** and create a sandbox key (`sk_sandbox_…`). Copy it immediately — secret keys are shown only once. ## 2. Install the SDK [#2-install-the-sdk] ```bash npm install @easylabs/node ``` ## 3. Create a recipient + send a payout [#3-create-a-recipient--send-a-payout] ```ts import { createClient } from "@easylabs/node"; const client = await createClient({ apiKey: process.env.EASY_API_KEY! }); // 3a. Create the recipient with their bank account in one call. const { data: recipient } = await client.createRecipient({ name: "Ada Lovelace", email: "ada@example.com", type: "person", payment_methods: [ { method_type: "ach", account_type: "checking", routing_number: "021000021", // Chase test routing account_number: "000123456789", // tokenized server-side }, ], }); // 3b. List your funding accounts and pick one to debit. const { data: funding } = await client.listBankAccounts(); const sourceAccount = funding[0]; // 3c. Initiate the payout. Amounts are USD cents. const { data: payout } = await client.createPayout({ recipient_id: recipient.id, source_account_id: sourceAccount.id, amount: 5_000, // $50.00 method: "ach", memo: "Invoice #1024", }); // 3d. Confirm. (Pass `security_code` if your security rules require 2FA.) await client.confirmPayout({ transaction_id: payout.id }); ``` ## 4. Verify [#4-verify] Open **Treasury → Transactions** in the dashboard — your payout will be in `processing`. Or fetch it programmatically: ```ts const { data } = await client.getTransaction(payout.id); console.log(data.status); // "processing" → "completed" ``` When the underlying [settlement](./concepts/settlement) is funded, a `settlement.created` webhook fires. See [Webhooks](/docs/sdks/node/webhooks) to wire that up. ## 5. Next steps [#5-next-steps] * [Send a payout](./guides/send-payout) — full reference for the send flow, including reversibility, rail trade-offs, and error handling. * [Invite a recipient](./guides/invite-recipient) — let recipients enter their own banking through Plaid Link instead of collecting account numbers. * [Generate a payout link](./guides/generate-payout-link) — share a link with someone who isn't a recipient yet. * [API reference](/docs/reference/api/treasury) — all Treasury endpoints. # Coupon and Promotion Code (/docs/billing/concepts/coupon-and-promotion-code) A **Coupon** is a reusable discount definition (percent or fixed amount, with a duration). A **Promotion Code** is a customer-redeemable shortcode that points at exactly one coupon and adds redemption controls (validity window, redemption cap, first-time-only, minimum amount). You apply a coupon directly when you have one in hand server-side; you accept a promotion code when a customer types one in. ## Lifecycle [#lifecycle] 1. **Create the coupon** with `createCoupon` — choose `percent_off` *or* `amount_off + currency`, plus `duration` (`once`, `repeating`, `forever`). For `repeating`, set `duration_in_months`. 2. **Optionally create promotion codes** with `createPromotionCode` referencing the `coupon_id`. The same coupon can back many codes. 3. **Validate** a code before applying with `validatePromotionCode` — returns `valid`, the resolved `coupon`, and a `discount_preview`. 4. **Apply** to a subscription via `applySubscriptionDiscount` (pass either `coupon_id` or `promotion_code`); scope to a single subscription item with `subscription_item_id` if needed. 5. **Remove** with `removeSubscriptionDiscount` to end the discount before its natural expiry. Coupons and promotion codes can be soft-disabled via `active: false` (`updateCoupon` / `updatePromotionCode`) or deleted outright. ## Relationships [#relationships] A `PromotionCodeData` always points at one `CouponData` (`coupon_id`). When applied to a subscription, both surface as a `SubscriptionDiscount` row referencing `coupon_id` and/or `promotion_code_id`. Coupons may be scoped to a subset of products via `applies_to_products`, in which case only invoice line items whose `price_id` belongs to one of those products receive the discount. ## Fields that matter [#fields-that-matter] * **Coupon `duration`** (`once` | `repeating` | `forever`) — controls how many billing cycles the discount applies for. `repeating` requires `duration_in_months`. * **Coupon `percent_off`** (`number`, 0–100) **or** **`amount_off`** (`number`, smallest currency unit) — exactly one is set; `amount_off` requires `currency`. * **Coupon `max_redemptions`** / **`times_redeemed`** — global cap across all customers; `applies_to_products` scopes the discount to specific products. * **Promotion code `code`** (`string`) — the human-typed token. Case is preserved as stored. * **Promotion code `first_time_only`** (`boolean`) — restricts to a customer's first redemption against this coupon family. * **Promotion code `minimum_amount`** (`number`) — the invoice or subscription subtotal that must be reached for the code to apply. ## Related [#related] * [API reference: Coupons](/docs/reference/api/billing/coupons) * [API reference: Promotion codes](/docs/reference/api/billing/promotion-codes) * [Guide: Apply a coupon](../guides/apply-coupon) # Customer portal (/docs/billing/concepts/customer-portal) The **Customer portal** is a hosted, white-label page where a customer can update payment methods, change plans, view invoices, and cancel subscriptions without leaving the merchant brand. There are two pieces: the **portal config** (`customer-portal-config`) — a per-merchant policy for what's enabled and how cancellations behave — and the **access flow** (`customer-portal/access/*`) — magic-link-based authentication that mints a session for one specific customer. ## Lifecycle [#lifecycle] 1. **Configure** the portal via `PATCH /customer-portal-config` — toggle features (`payment_methods_enabled`, `subscriptions_enabled`, `cancellations_enabled`), pick `cancellation_mode` (`immediately` or `end_of_period`), set `allow_plan_switch`, restrict `subscription_product_ids`, and supply a `return_url`. 2. **Request a link** by calling `POST /customer-portal/access/request-link` with the customer's `email`, `company_id`, and a `destination` (`home`, `payment_methods`, or `billing_information`). The platform emails the customer a magic link. 3. **Customer clicks the link**, which lands on the hosted portal and calls `POST /customer-portal/access/consume` to exchange the token for a portal session. 4. **In-portal actions** — payment-method add/remove, subscription cancel, plan switch, invoice download — all execute against the portal-scoped session, not your API key. 5. **Customer signs out** or the session expires; sign-out is `POST /customer-portal/access/sign-out`. ## Relationships [#relationships] The portal acts on the same [Customer](../../payments/concepts/customer), [Subscription](./subscription), and [Invoice](./invoice) records as the merchant API. Cancellations applied through the portal honor `cancellation_mode` and `cancellation_proration_enabled`, and may attach a `retention_coupon_id` automatically if configured. The portal is also the recommended `payment_failed_custom_link_url` target for [Dunning config](./dunning-config) so customers can fix failed payments themselves. ## Fields that matter [#fields-that-matter] * **`payment_methods_enabled`** + **`accepted_payment_methods`** (`string[]`) — gate which instruments customers can add (e.g. card, bank account) and whether the section appears at all. * **`subscriptions_enabled`** + **`allow_plan_switch`** + **`allow_quantity_updates`** — control the self-serve subscription management surface; restrict the catalog with `subscription_product_ids`. * **`cancellations_enabled`** + **`cancellation_mode`** (`immediately` | `end_of_period`) + **`cancellation_proration_enabled`** — define the cancel UX. `cancellation_reasons_enabled` + `cancellation_reasons` (`string[]`) collect a structured reason; `retention_coupon_id` offers a discount during the cancel flow. * **`subscription_proration_behavior`** (`none` | `prorate` | `full_difference`) + **`subscription_charge_timing`** (`immediately` | `period_end`) — billing semantics for plan changes initiated from the portal. * **`return_url`** (`string | null`) — where the portal sends the customer when they exit; used by the post-cancellation and post-update flows. * **`portal_header`** + **`custom_domain_id`** — branding overrides for the hosted page. ## Related [#related] * [API reference: Customer portal](/docs/reference/api/billing/customer-portal) * [API reference: Customer portal config](/docs/reference/api/billing/customer-portal-config) * [Guide: Launch the customer portal](../guides/launch-customer-portal) # Dunning config (/docs/billing/concepts/dunning-config) The **Dunning config** is the per-merchant policy for what happens when a recurring charge fails: how many times to retry, on what schedule, what email to send, where to send the customer to fix it, and what terminal state to move the subscription or invoice into when retries are exhausted. There is exactly one config per merchant; it applies to every subscription and `charge_automatically` invoice. ## Lifecycle [#lifecycle] 1. **Create or replace** the config with `createOrReplaceDunningConfig`. New merchants start with no config and the engine falls back to safe defaults until one exists. 2. **Read** the current policy with `getDunningConfig`. 3. **Patch** individual fields with `updateDunningConfig` — useful for toggling email enablement or swapping a recovery page mode without re-stating the full schedule. 4. The engine consults the config on every payment failure. It picks a retry slot from `smart_retry_window` (with `smart_retry_attempts` total) or steps through `custom_retry_schedule`. Bank-debit retries follow `bank_debit_retry_schedule` when `bank_debit_retries_enabled` is `true`. 5. When retries are exhausted, the engine applies `subscription_terminal_action` to the subscription (`cancel`, `unpaid`, `past_due`, `pause`) and `invoice_terminal_action` to any open invoice (`past_due`, `uncollectible`). `invoice.marked_uncollectible` and `subscription.updated` events fire accordingly. ## Relationships [#relationships] The dunning config governs every [Subscription](./subscription) and every [Invoice](./invoice) collected with `collection_method: "charge_automatically"`. Recovery emails link customers to either a hosted recovery page or a `custom_link_url` you supply (typically the [Customer portal](./customer-portal)). For event-driven workflows beyond retries, layer **revenue-recovery automations** on top via `createRevenueRecoveryAutomation` — these are conditional rules that fire on triggers like `invoice_overdue` and `subscription_payment_failed`. ## Fields that matter [#fields-that-matter] * **`retry_mode`** (`smart` | `custom`) — `smart` uses the platform-tuned schedule sized by `smart_retry_window` (`1_week`, `2_weeks`, `3_weeks`, `1_month`, `2_months`) and `smart_retry_attempts` (`4` or `8`); `custom` follows `custom_retry_schedule` (array of day offsets). * **`bank_debit_retries_enabled`** + **`bank_debit_retry_schedule`** — separate retry track for ACH/bank-debit failures, which have different reason codes and longer settlement windows than card declines. * **`subscription_terminal_action`** (`cancel` | `unpaid` | `past_due` | `pause`) — what state the subscription lands in after the final retry fails. * **`invoice_terminal_action`** (`past_due` | `uncollectible`) — what state the invoice lands in. `uncollectible` emits `invoice.marked_uncollectible` and stops further automated collection. * **`payment_failed_email_enabled`** / **`expiring_card_email_enabled`** / **`email_action_required`** — toggles for the three customer-facing email types the engine sends. * **`payment_failed_recovery_page_mode`** + **`payment_failed_custom_link_url`** — `hosted` directs to the white-label recovery page; `custom_link` deep-links to your own URL (e.g. a customer portal session). ## Related [#related] * [API reference: Dunning](/docs/reference/api/billing/auto-pay) * [Guide: Configure dunning](../guides/configure-dunning) * [Guide: Launch the customer portal](../guides/launch-customer-portal) # Billing concepts (/docs/billing/concepts) The Billing surface is built around 6 core entities. Each page covers what the entity is, how its lifecycle progresses, and how it relates to the rest of the model. # Invoice (/docs/billing/concepts/invoice) An **Invoice** is a billable statement issued to a customer. It collects line items, taxes, discounts, and shipping into a single `total_amount`, tracks how much has been collected (`amount_paid` / `amount_due`), and exposes the actions to deliver, charge, remind, void, or render a PDF. Invoices come from two sources: created directly via `createInvoice` for ad-hoc billing, or generated automatically by the [Subscription](./subscription) engine at each cycle. ## Lifecycle [#lifecycle] 1. **`DRAFT`** — created and editable. Patch with `updateInvoice` to add items, change amounts, or move dates. 2. **`OPEN`** — finalized and ready for collection. Subscription-generated invoices skip straight to `OPEN`. 3. **`SENT` / `VIEWED`** — `sendInvoice` delivers the email and (optionally) attaches the PDF; `last_sent_at` and `last_viewed_at` track engagement. Emits `invoice.created`, `invoice.finalized`. 4. **`OVERDUE` / `PARTIALLY_PAID`** — past `due_date` with an outstanding balance. Dunning may take over for `charge_automatically` invoices. Emits `invoice.payment_failed`. 5. **`PAID`** — `amount_due` reaches zero via `payInvoice` or the customer's hosted payment page. Emits `invoice.paid`. 6. **`VOID` / `VOIDED`** — terminated by `voidInvoice` or by a dunning terminal action. Emits `invoice.voided` or `invoice.marked_uncollectible`. ## Relationships [#relationships] An invoice references a buyer through `buyer_id` (a [Customer](../../payments/concepts/customer)) and may be linked from a [Subscription](./subscription) via `latest_invoice_id`. Each `InvoiceItem` may carry a `price_id` (linking back to a [Price](./product-and-price)) and `coupon_ids` for line-level discounts. Successful payments produce transfers visible under the customer's order history. ## Fields that matter [#fields-that-matter] * **`status`** (`InvoiceStatus`) — `DRAFT`, `OPEN`, `SENT`, `VIEWED`, `OVERDUE`, `PARTIALLY_PAID`, `PAID`, `UNPAID`, `VOID`, `VOIDED`. * **`collection_method`** (`charge_automatically` | `send_invoice`) — `charge_automatically` runs `payInvoice` against the customer's default instrument when the invoice opens; `send_invoice` waits for the customer to pay via the hosted page. * **`items`** (`InvoiceItem[]`) — each line carries `description`, `quantity`, `unit_price` (smallest currency unit), and optionally `price_id`, `coupon_ids`, `manual_tax_rate`. * **`due_date`** (ISO date) — required on create; drives `OVERDUE` and reminder behavior. * **`is_recurring`** + **`recurrence_interval`** — generates child invoices on `WEEKLY` / `BIWEEKLY` / `MONTHLY` / `QUARTERLY` / `YEARLY` cadence; pair with `recurrence_auto_send` to skip the manual send step. * **`amount_due`** / **`amount_paid`** / **`total_amount`** (number, smallest currency unit) — the running balance the dunning engine and webhooks act on. ## Related [#related] * [API reference](/docs/reference/api/billing/invoices) * [Guide: Send an invoice](../guides/send-invoice) * [Guide: Configure dunning](../guides/configure-dunning) # Product and Price (/docs/billing/concepts/product-and-price) A **Product** is the catalog entry for something you sell — a plan, a feature pack, a one-off SKU. A **Price** is a specific monetary amount + currency + recurrence attached to a product. A product may have many prices (currency variants, monthly vs. yearly, grandfathered tiers); subscriptions and invoices charge against a Price ID, never a Product ID. ## Lifecycle [#lifecycle] 1. **Create the product** with `createProduct`. `active: true` makes it usable for new prices and checkouts. 2. **Create one or more prices** with `createPrice`, passing the `product_id`. Prices are immutable in terms of `unit_amount`, `currency`, and recurrence — to change the amount, create a new price and migrate subscriptions. 3. **Set a default** by patching `default_price_id` on the product so checkouts and the customer portal can resolve a single price per product. 4. **Archive** with `archiveProduct` or `archivePrice` when retired. Archived products and prices remain valid on existing subscriptions but cannot be selected for new ones. ## Relationships [#relationships] A `PriceData` belongs to one `ProductData` (`product_id`). [Subscriptions](./subscription) reference prices through `SubscriptionItem.price_id`. [Invoice](./invoice) line items can link to a `price_id` for catalog-driven invoicing or use raw `unit_price` + `description` for ad-hoc lines. [Coupons](./coupon-and-promotion-code) may scope to specific products via `applies_to_products`. ## Fields that matter [#fields-that-matter] * **Product `name`** (`string`) and **`description`** (`string`) — surfaced on hosted invoices, checkouts, and the customer portal. * **Product `default_price_id`** (`string`) — the price used when callers don't specify one (e.g. customer portal plan switcher). * **Price `unit_amount`** (`number`, smallest currency unit — cents) and **`currency`** (`CurrencyCode`) — what gets charged per unit. * **Price `recurring`** (`boolean`) — `true` requires `interval` (`day` | `week` | `month` | `year`) and `interval_count`; `false` is a one-time price suitable for invoices and checkouts. * **Price `pricing_model`** (`PricingModel`) — `per_unit`, `tiered_volume`, `tiered_graduated`, `package`, `metered`, or `flat_rate`. `metered` requires usage reports (see [Meter usage](../guides/meter-usage)). * **Price `trial_period_days`** (`number`) — applies a default trial when the price is used to start a subscription without an explicit `trial_period_days` override. ## Related [#related] * [API reference: Products](/docs/reference/api/billing/products) * [API reference: Product (price)](/docs/reference/api/billing/product) * [Guide: Create a subscription](../guides/create-subscription) # Subscription (/docs/billing/concepts/subscription) A **Subscription** is a recurring charge agreement between a customer and the merchant. It binds a customer (`identity_id`) and a payment instrument to one or more priced items, and the billing engine generates invoices on each cycle and attempts collection automatically. ## Lifecycle [#lifecycle] 1. **`incomplete`** — created without a usable instrument or before initial payment confirmation. Resolves to `active` once payment succeeds, or to `incomplete_expired` if it lapses. 2. **`trialing`** — created with `trial_period_days` or `trial_end`. No invoices are generated until the trial ends. Emits `subscription.trial_will_end` ahead of expiry. 3. **`active`** — invoices are generated at each `current_period_end` and charged against `instrument_id`. Emits `subscription.created` then `subscription.updated` on item, quantity, or status changes. 4. **`past_due` / `unpaid`** — payment failed. Dunning takes over (see [Dunning config](./dunning-config)). Terminal action is governed by `subscription_terminal_action`. 5. **`paused`** — payment collection suspended via `pauseSubscription`. Emits `subscription.paused`; `subscription.resumed` on resume. 6. **`canceled`** — terminated by `cancelSubscription` (immediate) or `cancel_at_period_end: true` (deferred). Emits `subscription.deleted` once `ended_at` is set. ## Relationships [#relationships] A subscription belongs to a [Customer](../../payments/concepts/customer) (`identity_id`) and references a payment instrument (`instrument_id`). Each [`SubscriptionItem`](/docs/reference/api/billing/subscriptions) points at a [Price](./product-and-price) which belongs to a Product. Generated [Invoices](./invoice) are linked back via `latest_invoice_id`. Discounts attach via [`SubscriptionDiscount`](./coupon-and-promotion-code) and reference a coupon or promotion code. ## Fields that matter [#fields-that-matter] * **`status`** (`SubscriptionStatus`) — drives billing behavior; one of `incomplete`, `incomplete_expired`, `trialing`, `active`, `past_due`, `unpaid`, `canceled`, `paused`. * **`items`** (`SubscriptionItem[]`) — the priced lines that produce invoice line items each cycle. Mutate with `addSubscriptionItem` / `updateSubscriptionItem` / `removeSubscriptionItem`. * **`current_period_start` / `current_period_end`** (ISO datetime) — bounds of the current billing window. Next invoice is generated at `current_period_end`. * **`cancel_at_period_end`** (`boolean`) — when `true`, the subscription continues until `current_period_end`, then transitions to `canceled`. * **`pause_collection`** (`{ behavior, resumes_at } | null`) — populated while paused; `behavior` controls how draft invoices during the pause are handled. * **`pending_update`** (`SubscriptionPendingUpdate | null`) — staged item changes awaiting trial-end or next renewal. ## Related [#related] * [API reference](/docs/reference/api/billing/subscriptions) * [Guide: Create a subscription](../guides/create-subscription) * [Guide: Apply a coupon](../guides/apply-coupon) # Apply a coupon (/docs/billing/guides/apply-coupon) ## Goal [#goal] Take a coupon ID (server-side) or a customer-typed promotion code (e.g. `LAUNCH50`), validate that it can be redeemed, and attach the resulting discount to a subscription. Coupons can also be scoped to specific products and to a single subscription item rather than the whole subscription. ## Prerequisites [#prerequisites] * A sandbox or production API key * `@easylabs/node` installed * Either a [Coupon](../concepts/coupon-and-promotion-code) you've already created, or a Promotion code that points at one * An existing [Subscription](./create-subscription) to apply the discount to ## Implementation [#implementation] ### 1. Create the coupon (one-time setup) [#1-create-the-coupon-one-time-setup] Coupons are reusable — create them once for each campaign or pricing rule. Pick `percent_off` or `amount_off + currency`, never both. ```ts import { createClient } from "@easylabs/node"; const easy = await createClient({ apiKey: process.env.EASY_API_KEY! }); const { data: coupon } = await easy.createCoupon({ duration: "repeating", duration_in_months: 3, percent_off: 25, name: "Launch month — 25% off for 3 months", max_redemptions: 500, }); // Optional: a customer-redeemable code that points at this coupon. const { data: promo } = await easy.createPromotionCode({ coupon_id: coupon.id, code: "LAUNCH25", first_time_only: true, minimum_amount: 1000, valid_until: "2026-12-31T23:59:59Z", }); ``` ### 2. Validate a customer-entered code [#2-validate-a-customer-entered-code] Always validate before applying — the call returns a `discount_preview` you can render in your UI ("$12.25 off") and a `valid: false` reason ("expired", "minimum\_not\_met") when the code can't be used. ```ts const { data: validation } = await easy.validatePromotionCode({ code: "LAUNCH25", identity_id: "cus_01HXXXXXXXXXXX", amount: 4900, // optional — checks minimum_amount if set }); if (!validation.valid) { throw new Error(validation.reason ?? "Invalid promo code"); } ``` ### 3. Apply the discount to the subscription [#3-apply-the-discount-to-the-subscription] Pass exactly one of `coupon_id` or `promotion_code`. Optionally scope to a single subscription item by passing `subscription_item_id` (otherwise the discount applies to the subscription as a whole). ```ts // Apply via promotion code (typical for customer-entered codes) const { data: discount } = await easy.applySubscriptionDiscount( "sub_01HXXXXXXXXXXX", { promotion_code: "LAUNCH25" }, ); // Apply via coupon directly (typical for server-side / programmatic discounts) await easy.applySubscriptionDiscount( "sub_01HXXXXXXXXXXX", { coupon_id: coupon.id, subscription_item_id: "si_01HXXXXXXXXXXX" }, ); ``` The discount is honored on the next invoice generated by the subscription engine. For `repeating` coupons, the discount lasts `duration_in_months` invoices and then drops off automatically. ### 4. Inspect or remove later [#4-inspect-or-remove-later] ```ts const { data: discounts } = await easy.listSubscriptionDiscounts("sub_01HXXXXXXXXXXX"); await easy.removeSubscriptionDiscount("sub_01HXXXXXXXXXXX", discounts[0].id); ``` ## Tradeoffs [#tradeoffs] * **Coupon vs. promotion code** — apply a `coupon_id` directly when you control the discount logic server-side (anniversary perks, retention saves, internal credits). Use a `code` when the customer types it in. * **Subscription-level vs. item-level scope** — leaving `subscription_item_id` unset discounts the whole subscription. Set it to scope to a single line — useful when an add-on is the discounted product but the base plan is not. * **`first_time_only` is per-coupon, not per-product** — once a customer redeems any code that maps to a given coupon, no other code mapping to that same coupon will validate for them with `first_time_only: true`. ## Related [#related] * [Concept: Coupon and promotion code](../concepts/coupon-and-promotion-code) * [API reference: Coupons](/docs/reference/api/billing/coupons) * [API reference: Promotion codes](/docs/reference/api/billing/promotion-codes) # Configure dunning (/docs/billing/guides/configure-dunning) ## Goal [#goal] Define how the platform retries failed recurring charges, what the customer sees when a payment fails, and what state the subscription or invoice ends up in if recovery exhausts. There is exactly one dunning config per merchant — set it once, then iterate. Pair this with **revenue-recovery automations** for event-driven follow-up beyond retries. ## Prerequisites [#prerequisites] * A sandbox or production API key * `@easylabs/node` installed * Active [subscriptions](./create-subscription) and/or `charge_automatically` [invoices](./send-invoice) — dunning has nothing to do until something fails ## Implementation [#implementation] ### 1. Set the retry policy [#1-set-the-retry-policy] Pick `smart` for a platform-tuned schedule sized by `smart_retry_window` and `smart_retry_attempts`, or `custom` if you need precise day offsets. ```ts import { createClient } from "@easylabs/node"; const easy = await createClient({ apiKey: process.env.EASY_API_KEY! }); await easy.createOrReplaceDunningConfig({ retry_mode: "smart", smart_retry_attempts: 8, smart_retry_window: "2_weeks", // Bank-debit failures need their own track (longer settlement windows). bank_debit_retries_enabled: true, bank_debit_retry_schedule: [3, 7, 14], // Customer-facing emails on failure / expiring card. payment_failed_email_enabled: true, expiring_card_email_enabled: true, card_expiry_warn_days: 14, // Where the recovery email sends the customer. payment_failed_recovery_page_mode: "custom_link", payment_failed_custom_link_url: "https://your-app.com/billing/recover", // Terminal actions when retries are exhausted. subscription_terminal_action: "past_due", invoice_terminal_action: "uncollectible", }); ``` ### 2. Read or patch later [#2-read-or-patch-later] `createOrReplaceDunningConfig` is destructive — it replaces every field. Use `updateDunningConfig` to change a subset: ```ts await easy.updateDunningConfig({ smart_retry_attempts: 4 }); const { data: current } = await easy.getDunningConfig(); ``` ### 3. Add revenue-recovery automations (optional) [#3-add-revenue-recovery-automations-optional] Automations are conditional rules layered on top of the retry engine. They fire on triggers like `invoice_overdue` and run actions like `email_team_member` or `mark_invoice_uncollectible`. ```ts await easy.createRevenueRecoveryAutomation({ name: "Notify CSM on enterprise overdue", trigger_type: "invoice_overdue", conditions: [ { type: "invoice_amount", operator: "more_than", amount_cents: 100_000 }, ], actions: [ { type: "email_team_member", delay_days: 1, recipient_email: "csm@your-app.com" }, ], active: true, }); ``` ### 4. Listen for terminal events [#4-listen-for-terminal-events] Subscribe to `invoice.payment_failed`, `invoice.marked_uncollectible`, `subscription.updated` (status transitions to `past_due` / `unpaid`), and `revenue_recovery.action_completed` to log dunning outcomes and notify your team. ## Tradeoffs [#tradeoffs] * **`smart` vs. `custom` retries** — `smart` adapts retry timing to issuer behavior and typically recovers more revenue than a fixed schedule; reach for `custom` only when you have hard requirements (e.g. quiet hours). * **Terminal action `cancel` vs. `past_due`** — `cancel` ends the subscription and stops downstream charges; `past_due` keeps it visible so a customer can self-recover via the portal. Most SaaS picks `past_due` + customer-portal recovery. * **Recovery page mode `hosted` vs. `custom_link`** — hosted is zero-effort and white-labeled; `custom_link` lets you deep-link to a [customer-portal](./launch-customer-portal) magic link with the failure context already loaded. ## Related [#related] * [Concept: Dunning config](../concepts/dunning-config) * [API reference](/docs/reference/api/billing/auto-pay) * [Guide: Launch the customer portal](./launch-customer-portal) # Create a subscription (/docs/billing/guides/create-subscription) ## Goal [#goal] Create an `active` (or `trialing`) subscription that charges a customer's saved payment instrument on a fixed cadence. Use this whenever you sell a recurring plan — SaaS seats, monthly memberships, repeat-fulfillment products. For one-off invoices, see [Send an invoice](./send-invoice). For self-serve plan changes after the subscription exists, see [Launch the customer portal](./launch-customer-portal). ## Prerequisites [#prerequisites] * A sandbox or production API key * `@easylabs/node` installed * A [Customer](../../payments/concepts/customer) and a saved [payment instrument](../../payments/concepts/payment-instrument) belonging to that customer * A recurring [Price](../concepts/product-and-price) (`recurring: true`) ## Implementation [#implementation] ### 1. Resolve or create the price [#1-resolve-or-create-the-price] Subscriptions charge against a price ID, not a product ID. If you don't already have one, create the price (or look it up) before continuing: ```ts import { createClient } from "@easylabs/node"; const easy = await createClient({ apiKey: process.env.EASY_API_KEY! }); const { data: price } = await easy.createPrice({ product_id: "prod_01HXXXXXXXXXXX", active: true, recurring: true, currency: "USD", unit_amount: 4900, // $49.00 interval: "month", interval_count: 1, tax_behavior: "exclusive", }); ``` ### 2. Create the subscription [#2-create-the-subscription] Pass `identity_id`, `instrument_id`, and one or more `items`. The first invoice is generated synchronously; the call resolves once the subscription is `active` (or `trialing` if you supply a trial). ```ts const { data: subscription } = await easy.createSubscription({ identity_id: "cus_01HXXXXXXXXXXX", instrument_id: "pi_01HXXXXXXXXXXX", items: [{ price_id: price.id, quantity: 3 }], metadata: { workspace_id: "ws_42" }, }); ``` To start with a free trial, omit `instrument_id` and supply `trial_period_days` (or an explicit `trial_end` ISO datetime): ```ts await easy.createSubscription({ identity_id: "cus_01HXXXXXXXXXXX", items: [{ price_id: price.id, quantity: 1 }], trial_period_days: 14, }); ``` ### 3. React to lifecycle events [#3-react-to-lifecycle-events] Wire your webhook endpoint to act on `subscription.created`, `subscription.updated`, `subscription.trial_will_end`, `invoice.paid`, and `invoice.payment_failed`. See the [webhooks reference](/docs/reference/webhooks) for signature verification with `EasyWebhooks.constructEvent`. ### 4. Mutate later [#4-mutate-later] Add a seat: `await easy.addSubscriptionItem(subscription.id, { price_id, quantity: 1 })`. Change quantity: `updateSubscriptionItem(subscription.id, itemId, { quantity: 5 })`. Cancel at period end: `updateSubscription(subscription.id, { cancel_at_period_end: true })`. Cancel immediately: `cancelSubscription(subscription.id)`. Preview the proration impact of a change first with `getSubscriptionProrationPreview`. ## Tradeoffs [#tradeoffs] * **Trial without an instrument** is fine, but the subscription will move to `incomplete` at trial end if no `instrument_id` has been attached. Schedule a reminder before `trial_end` and patch the subscription with `updateSubscription({ instrument_id })`. * **Proration behavior** defaults to `create_prorations` on item changes. Pass `proration_behavior: "none"` for grandfathered customers, or `"always_invoice"` to bill the proration immediately rather than rolling into the next cycle. * **Skip the subscription engine entirely** when billing is per-event or quote-style — use [`createInvoice`](./send-invoice) instead and avoid the cycle bookkeeping. ## Related [#related] * [Concept: Subscription](../concepts/subscription) * [Concept: Product and Price](../concepts/product-and-price) * [API reference](/docs/reference/api/billing/subscriptions) # Billing guides (/docs/billing/guides) Step-by-step recipes for the most common Billing integrations. Each guide states the goal, lists prerequisites, and ships working code. # Launch the customer portal (/docs/billing/guides/launch-customer-portal) ## Goal [#goal] Let a customer manage their own subscriptions, payment methods, and invoices in a hosted, white-label page. You configure what the portal exposes once, then mint a magic link per session — no portal-specific UI to build, and your API key never leaves the server. The portal is also the recommended target for failed-payment recovery emails (see [Configure dunning](./configure-dunning)). ## Prerequisites [#prerequisites] * A sandbox or production API key * A [Customer](../../payments/concepts/customer) with a known `email` * One configuration pass through `PATCH /customer-portal-config` to enable the sections you want (payment methods, subscriptions, cancellations) — see the [config reference](/docs/reference/api/billing/customer-portal-config) ## Implementation [#implementation] ### 1. Configure the portal once [#1-configure-the-portal-once] Toggle features and define cancellation semantics. Done once per environment, not per customer. ```ts // PATCH /v1/api/customer-portal-config await fetch(`${EASY_API_URL}/v1/api/customer-portal-config/`, { method: "PATCH", headers: { "x-easy-api-key": process.env.EASY_API_KEY!, "Content-Type": "application/json", }, body: JSON.stringify({ payment_methods_enabled: true, accepted_payment_methods: ["card", "bank_account"], subscriptions_enabled: true, allow_plan_switch: true, allow_quantity_updates: true, cancellations_enabled: true, cancellation_mode: "end_of_period", cancellation_proration_enabled: false, cancellation_reasons_enabled: true, cancellation_reasons: ["Too expensive", "Missing features", "Switching tools"], return_url: "https://your-app.com/account", invoice_history_enabled: true, }), }); ``` ### 2. Request a magic link for a customer [#2-request-a-magic-link-for-a-customer] When a signed-in user clicks "Manage billing" in your app, call the request-link endpoint server-side. The platform emails the link to the customer. ```ts type RequestLinkBody = { company_id: string; email: string; destination?: "home" | "payment_methods" | "billing_information"; destination_context?: Record; }; async function requestPortalLink(body: RequestLinkBody) { const res = await fetch(`${EASY_API_URL}/v1/api/customer-portal/access/request-link`, { method: "POST", headers: { "x-easy-api-key": process.env.EASY_API_KEY!, "Content-Type": "application/json", }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(`request-link failed: ${res.status}`); return res.json(); } await requestPortalLink({ company_id: process.env.EASY_COMPANY_ID!, email: "ada@example.com", destination: "payment_methods", }); ``` The `destination` controls the landing section. Use `payment_methods` from a "Update your card" prompt and `home` from a generic "Manage billing" link. ### 3. Customer follows the link [#3-customer-follows-the-link] The customer clicks the email, the portal loads, and a token in the URL is exchanged for a session via `POST /customer-portal/access/consume`. Everything from that point — payment-method updates, plan switches, cancels — runs against that scoped session, not your API key. ### 4. React to portal-driven changes [#4-react-to-portal-driven-changes] The portal mutates the same records as the merchant API, so your existing webhook handlers will fire. Watch for `subscription.updated` (plan switches, quantity changes), `subscription.deleted` (cancellations), `invoice.paid` (recovered failures), and act in your app accordingly. ## Tradeoffs [#tradeoffs] * **Magic link delivery is asynchronous** — if your UI promises "we just emailed you", make sure your transactional email provider is set up. For a same-tab handoff (no email round-trip), use the access-handoff create/exchange pair instead. * **`cancellation_mode: "immediately"` vs. `"end_of_period"`** — immediate cancels free the seat now but lose the remaining paid period; end-of-period preserves goodwill and is the conventional SaaS default. * **Self-serve plan switching** is convenient but skips your in-app upsell logic. Restrict to a curated catalog with `subscription_product_ids` if you only want certain plans available in the portal. ## Related [#related] * [Concept: Customer portal](../concepts/customer-portal) * [API reference: Customer portal](/docs/reference/api/billing/customer-portal) * [Guide: Configure dunning](./configure-dunning) # Meter usage (/docs/billing/guides/meter-usage) ## Goal [#goal] Bill a customer for variable consumption — API calls, GB ingested, minutes streamed — by reporting usage events against a subscription item with a `metered` price. The platform aggregates the reports, generates an invoice line at period close, and exposes a reconciliation view so you can match what you reported against what was billed. ## Prerequisites [#prerequisites] * A sandbox or production API key * `@easylabs/node` installed * A [Price](../concepts/product-and-price) created with `pricing_model: "metered"` * A [Subscription](./create-subscription) with at least one item using that price ## Implementation [#implementation] ### 1. Report usage as it happens [#1-report-usage-as-it-happens] Each event references the `subscription_item_id` and a quantity. Pass `action: "increment"` (the default) to add to the bucket, or `action: "set"` to overwrite the period total. Use `idempotency_key` to make retries safe. ```ts import { createClient } from "@easylabs/node"; const easy = await createClient({ apiKey: process.env.EASY_API_KEY! }); await easy.reportSubscriptionUsage("sub_01HXXXXXXXXXXX", { subscription_item_id: "si_01HXXXXXXXXXXX", quantity: 1, action: "increment", timestamp: new Date().toISOString(), idempotency_key: `evt_${eventId}`, }); ``` For high-volume metering, batch on your side and submit in larger chunks rather than one event per API call. `quantity` is an integer; pre-aggregate fractional units to whole ones at the resolution you're billing. ### 2. Read the running total [#2-read-the-running-total] `getSubscriptionUsageSummary` returns the current period's accumulated usage by item. Use `as_of` to view a historical snapshot or `from`/`to` to bound the window. ```ts const { data: summary } = await easy.getSubscriptionUsageSummary( "sub_01HXXXXXXXXXXX", { subscription_item_id: "si_01HXXXXXXXXXXX" }, ); ``` Surface this in your customer-facing dashboard so usage is visible before the invoice closes. ### 3. Reconcile at period end [#3-reconcile-at-period-end] When the subscription rolls over, the engine generates an invoice line for the metered totals. `getSubscriptionUsageReconciliation` returns the reported-vs-billed comparison for a closed period — call it from a scheduled job and alert if the deltas exceed your tolerance. ```ts const { data: recon } = await easy.getSubscriptionUsageReconciliation( "sub_01HXXXXXXXXXXX", { subscription_item_id: "si_01HXXXXXXXXXXX", period_start: "2026-04-01T00:00:00Z", period_end: "2026-05-01T00:00:00Z", }, ); ``` Listen for `invoice.created` and `invoice.finalized` to know when to run reconciliation. ## Tradeoffs [#tradeoffs] * **`increment` vs. `set`** — `increment` is the default and fits event streams. `set` is the right call only when you have an authoritative period total (e.g. nightly batch from a data warehouse) and you can guarantee one report per period. * **Latency** — usage reports are not invoiced in real time. Reports flushed within the period are billed; reports backfilled after the period closes go on the next invoice (or fail to record, depending on configuration). Bound your reporting lag to less than the billing interval. * **Idempotency keys** are per subscription item, not global. A retry with the same `idempotency_key` is safely deduped; reusing the key for a new event will silently drop it. ## Related [#related] * [Concept: Subscription](../concepts/subscription) * [Concept: Product and Price](../concepts/product-and-price) * [API reference](/docs/reference/api/billing/subscriptions) # Send an invoice (/docs/billing/guides/send-invoice) ## Goal [#goal] Create an invoice from line items, send it to a customer's email, and (optionally) attach the PDF. Use this for one-off billing — quotes, professional services, milestone payments — or when you want the customer to pay through a hosted page rather than charging a saved instrument. For recurring billing of a fixed plan, use [subscriptions](./create-subscription) instead. ## Prerequisites [#prerequisites] * A sandbox or production API key * `@easylabs/node` installed * A `to_email` for the recipient (and optionally a `buyer_id` linking to a [Customer](../../payments/concepts/customer)) ## Implementation [#implementation] ### 1. Create the invoice [#1-create-the-invoice] Items carry `description`, `quantity`, and `unit_price` in the smallest currency unit. The invoice starts in `DRAFT`; pass `collection_method` to control how it gets paid once finalized. ```ts import { createClient } from "@easylabs/node"; const easy = await createClient({ apiKey: process.env.EASY_API_KEY! }); const { data: invoice } = await easy.createInvoice({ to_email: "ada@example.com", to_company_name: "Lovelace Labs", buyer_id: "cus_01HXXXXXXXXXXX", currency: "USD", collection_method: "send_invoice", // customer pays via hosted page due_date: "2026-06-01", items: [ { description: "Q2 retainer", quantity: 1, unit_price: 500_000 }, // $5,000.00 { description: "Add-on workshop", quantity: 2, unit_price: 75_000, manual_tax_rate: 8.875 }, ], notes: "Thanks for working with us — net 30.", auto_reminders: true, include_payment_page_link: true, }); ``` ### 2. Send the email [#2-send-the-email] `sendInvoice` finalizes the invoice (DRAFT → OPEN), delivers the email, and returns the updated record. Pass `attach_pdf: true` to embed the PDF. ```ts await easy.sendInvoice(invoice.id, { attach_pdf: true, cc_recipients: ["billing@lovelacelabs.example"], }); ``` To schedule the send for a future time, pass `scheduled_send_at` instead of calling immediately, or set it on `createInvoice` and skip the manual `sendInvoice` step. ### 3. Charge automatically (optional) [#3-charge-automatically-optional] If you'd rather collect against the customer's default payment instrument the moment the invoice opens, pass `collection_method: "charge_automatically"` on create and call `payInvoice` (or let the platform pull on `due_date`): ```ts await easy.payInvoice(invoice.id, { // omit instrument_id to use the customer's default idempotency_key: `invoice-${invoice.id}-attempt-1`, }); ``` ### 4. React to status [#4-react-to-status] Listen for `invoice.finalized`, `invoice.paid`, `invoice.payment_failed`, `invoice.voided`, and `invoice.marked_uncollectible`. Send manual nudges with `remindInvoice(invoice.id)` between auto-reminders, or `voidInvoice(invoice.id)` to cancel an open one. ## Tradeoffs [#tradeoffs] * **`send_invoice` vs. `charge_automatically`** — pick `send_invoice` when the customer expects to pay manually (typical for B2B). Pick `charge_automatically` only when you have an instrument on file and the customer has agreed to be billed. * **Recurring invoices** (`is_recurring: true` + `recurrence_interval`) are simpler than subscriptions but lack proration, item-level mutations, and the dunning lifecycle. Choose subscriptions when the relationship is open-ended. * **Tax** — `manual_tax_rate` on each item is the lowest-friction option. For multi-jurisdiction tax, set `tax_rate_id` on prices and let the platform compute totals. ## Related [#related] * [Concept: Invoice](../concepts/invoice) * [API reference](/docs/reference/api/billing/invoices) * [Guide: Configure dunning](./configure-dunning) # Migrate from Stripe to Easy Billing (/docs/billing/migration/from-stripe) This page is a side-by-side reference. The two APIs share the Customer / Product / Price / Subscription / Invoice / Coupon / Promotion code model, so most code is a method-name swap and a small payload translation. Differences worth knowing are called out under [Object-model differences](#object-model-differences). ## Subscriptions [#subscriptions] | Stripe | Easy Labs (`@easylabs/node`) | Notes | | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `stripe.subscriptions.create({ customer, items, default_payment_method })` | `easy.createSubscription({ identity_id, items, instrument_id })` | `customer` → `identity_id`; `default_payment_method` → `instrument_id`. Items use `price_id` + `quantity` in both. | | `stripe.subscriptions.retrieve(id)` | `easy.getSubscription(id)` | Returns `{ data: SubscriptionData }`. | | `stripe.subscriptions.update(id, body)` | `easy.updateSubscription(id, body)` | `cancel_at_period_end`, `proration_behavior`, `items`, `metadata` are 1:1. | | `stripe.subscriptions.cancel(id, { invoice_now })` | `easy.cancelSubscription(id, { at_period_end })` | Easy treats `at_period_end: false` (default) as "cancel now". | | `stripe.subscriptions.update(id, { pause_collection })` | `easy.pauseSubscription(id, { behavior })` / `easy.resumeSubscription(id)` | Dedicated endpoints. `behavior` is `void` / `keep_as_draft` / `mark_uncollectible`. | | `stripe.subscriptionItems.create / update / del` | `easy.addSubscriptionItem` / `updateSubscriptionItem` / `removeSubscriptionItem` | Same shape. | | `stripe.invoices.retrieveUpcoming({ subscription, subscription_items })` | `easy.getSubscriptionProrationPreview(id, { items, remove_items })` | Returns the proration delta only (not a full upcoming invoice). | | `stripe.subscriptionItems.createUsageRecord` | `easy.reportSubscriptionUsage(subscriptionId, body)` | Same `quantity` + `action` (`increment` / `set`) + `timestamp` semantics. | ## Invoices [#invoices] | Stripe | Easy Labs | Notes | | --------------------------------------------------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | | `stripe.invoices.create(body)` | `easy.createInvoice(body)` | `customer` → `buyer_id`; line items pass inline as `items[]` rather than separate `invoiceItems.create` calls. | | `stripe.invoices.finalizeInvoice(id)` + `sendInvoice(id)` | `easy.sendInvoice(id, { attach_pdf, cc_recipients })` | One call: finalizes *and* sends. | | `stripe.invoices.pay(id, { payment_method })` | `easy.payInvoice(id, { instrument_id })` | Use `idempotency_key` on retries. | | `stripe.invoices.sendInvoice(id)` (reminder) | `easy.remindInvoice(id)` | Sends a follow-up email on an already-sent invoice. | | `stripe.invoices.voidInvoice(id)` | `easy.voidInvoice(id)` | Same. | | `stripe.invoices.list({ status, collection_method })` | `easy.listInvoices({ status, collection_method, due_date_from, due_date_to })` | `status` enum is uppercase (`OPEN`, `PAID`, ...) on Easy. | ## Coupons and promotion codes [#coupons-and-promotion-codes] | Stripe | Easy Labs | Notes | | ---------------------------------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | `stripe.coupons.create({ percent_off, duration, duration_in_months })` | `easy.createCoupon({ percent_off, duration, duration_in_months })` | Same. Or pass `amount_off` + `currency` for fixed discounts. | | `stripe.promotionCodes.create({ coupon, code })` | `easy.createPromotionCode({ coupon_id, code })` | `coupon` → `coupon_id`. Same `first_time_only`, `max_redemptions`, `minimum_amount`. | | `stripe.promotionCodes.list({ code })` then check active | `easy.validatePromotionCode({ code, identity_id, amount })` | Single call returns `valid`, `coupon`, `discount_preview`, and `reason`. | | `stripe.subscriptions.update(id, { discounts: [{ coupon }] })` | `easy.applySubscriptionDiscount(id, { coupon_id })` or `{ promotion_code }` | Dedicated endpoint. `subscription_item_id` scopes to one item. | ## Customer portal [#customer-portal] | Stripe | Easy Labs | Notes | | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `stripe.billingPortal.configurations.update(...)` | `PATCH /v1/api/customer-portal-config/` | Toggles for payment methods, subscriptions, cancellations. | | `stripe.billingPortal.sessions.create({ customer, return_url })` | `POST /v1/api/customer-portal/access/request-link` (`{ company_id, email, destination }`) | Easy emails the customer a magic link instead of returning a session URL directly. For an inline same-tab handoff, use the `access/handoff/create` + `access/handoff/exchange` pair. | ## Webhooks [#webhooks] Both platforms sign webhooks with HMAC-SHA256 over the raw body. Headers and helpers: | Stripe | Easy Labs | | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `Stripe-Signature` header + `stripe.webhooks.constructEvent(body, sig, secret)` | `x-easy-webhook-signature: sha256=` header + `EasyWebhooks.constructEvent(body, sig, secret)` from `@easylabs/node` | | `customer.subscription.created` / `.updated` / `.deleted` | `subscription.created` / `.updated` / `.deleted` | | `customer.subscription.trial_will_end` | `subscription.trial_will_end` | | `customer.subscription.paused` / `.resumed` | `subscription.paused` / `.resumed` | | `invoice.created` / `.finalized` / `.paid` / `.payment_failed` / `.voided` | Same names. | | `invoice.marked_uncollectible` | `invoice.marked_uncollectible` | | `coupon.created` / `.updated` / `.deleted` | `coupon.created` / `.updated` / `.deleted` | | `promotion_code.created` / `.updated` | `promotion_code.created` / `.updated` / `.deleted` | | `checkout.session.completed` | `checkout.session.completed` (plus `checkout.session.crypto_confirmed` if you accept crypto) | See the [webhooks reference](/docs/reference/webhooks) for the full event list and signature format. ## Object-model differences [#object-model-differences] * **Customer is `Identity`** under the hood — most fields you read are nested under `data.entity.*`. Anywhere Stripe takes `customer`, Easy takes `identity_id`. * **Payment method is "instrument"** — `payment_method` → `instrument_id`. The shape returned from `createPaymentInstrument` carries instrument-level enable/disable flags rather than card-level metadata. * **No detached invoice items.** Stripe's two-step `invoiceItems.create` + `invoices.create` is collapsed: pass `items` inline on `createInvoice`. * **Single dunning policy per merchant.** Stripe's per-customer retry rules are replaced by `dunning-config` + revenue-recovery automations applied globally. * **Money is in the smallest currency unit** (cents) on both platforms. `currency` is uppercase ISO 4217 on Easy (e.g. `"USD"`). * **Status enums** are uppercase on Easy invoices (`OPEN`, `PAID`, `VOID`) but lowercase on subscriptions (`active`, `trialing`, `past_due`) — matching the underlying engines. * **No `expand`.** Endpoints return the linked records they need (e.g. `getProductWithPrices`); there is no generic expansion mechanism. ## Related [#related] * [Billing concepts](../concepts/subscription) * [API reference](/docs/reference/api/billing) * [Webhooks reference](/docs/reference/webhooks) # Migrate to Easy Billing (/docs/billing/migration) Mechanical mappings from competing APIs to Easy Billing. Each migration page covers method-by-method translation, webhook event pairs, and meaningful conceptual differences. # Customer (/docs/payments/concepts/customer) A **Customer** represents a buyer (or payer) you transact with. It is the durable identity that owns Payment Instruments, Orders, Subscriptions, and Disputes — every charge in Payments either belongs to a Customer or is created on the fly during checkout (which still creates a Customer behind the scenes). Use a Customer when you need to remember the buyer across sessions: saved cards, recurring billing, refunds against the right account, dispute correlation. ## Lifecycle [#lifecycle] 1. **Created** — `client.createCustomer({ first_name, last_name, email, phone, personal_address })` returns a `CustomerData` record with an `id`. No webhook fires for creation; the API response is the source of truth. 2. **Used** — the `id` is passed as `identityId` when creating a Payment Instrument, or as `identity_id` on a Subscription / Embedded Checkout session. 3. **Updated** — `client.updateCustomer(customerId, partial)` patches mutable fields. Tags accept arbitrary JSON for your own bookkeeping. 4. **Identity events** — `identity.created` and `identity.updated` webhook events fire when the underlying processor identity changes (typically the same lifecycle, but they are the audit trail you should subscribe to if you mirror customer state into your own database). Customers are not deleted via the API. To stop charging a Customer, disable their Payment Instruments or cancel their Subscriptions instead. ## Relationships [#relationships] Every **Order**, **Transfer**, and **Subscription** ultimately resolves to a Customer (the payer). A Customer can own many **Payment Instruments** (cards, bank accounts) and many **Wallets** (Solana addresses for crypto checkout). **Disputes** are reachable through the Transfer that was charged on one of the Customer's Payment Instruments. ## Fields that matter [#fields-that-matter] * `id` (`string`) — the canonical Customer ID. Pass this as `identityId` when creating a Payment Instrument and as `identity_id` for subscriptions. * `entity.first_name`, `entity.last_name`, `entity.email`, `entity.phone` (`string`) — buyer contact info. Required at creation. * `entity.personal_address` (`Address | null`) — used for AVS on card transactions; pass it when you have it. * `tags` (`Record`) — your own metadata (internal user IDs, plan tier, etc.). Searchable; keep keys stable. * `identity_roles` (`string[]`) — processor-side flags (e.g. whether this identity has merchant capabilities). Read-only. ## Related [#related] * [API reference](/docs/reference/api/payments/customer) * [Guide: Accept a card payment](../guides/accept-card-payment) * [Guide: Embed hosted checkout](../guides/embed-hosted-checkout) # Dispute (/docs/payments/concepts/dispute) A **Dispute** is an issuer- or buyer-initiated chargeback against a Transfer: the buyer's bank pulls the funds back into their account and asks Easy Labs (and you, the merchant) to either accept the loss or contest it with evidence. Disputes are read-mostly from the SDK's perspective — they are created by the network, not by you, and the work you do is responding to them with evidence and tracking the outcome. The Dispute object is the durable record of that exchange. ## Lifecycle [#lifecycle] 1. **Created** — a `dispute.created` webhook fires when the network notifies Easy Labs of a chargeback. The disputed amount has already been pulled from the merchant settlement. A separate Transfer with `type: "DISPUTE"` holds the funds in flight. 2. **Under review** — you have a fixed evidence window (network-dependent; typically 7–21 days) to upload supporting documentation through the dashboard or by attaching it via your account team. 3. **Decision** — the issuer decides. A `dispute.updated` webhook fires when the status changes. Outcomes are typically `WON` (funds returned to merchant), `LOST` (funds stay with buyer), or `ACCEPTED` (you chose not to contest). 4. **Tagged** — at any point you can call `client.updateDispute(disputeId, { internalCaseId: "…" })` to attach your own metadata. Tags are the only mutable surface on a Dispute. ## Relationships [#relationships] Every Dispute references the **Transfer** it is contesting. From the Transfer you can resolve back to the **Order** (if the charge originated from a checkout flow), the **Customer** that owns the funding source, and the **Payment Instrument** that was charged. ## Fields that matter [#fields-that-matter] The Dispute schema is processor-specific and the API exposes it as a permissive object; the fields you can rely on are: * `id` (`string`) — the canonical Dispute ID. Use with `getDispute`, `updateDispute`. * `amount` (`number`) — disputed amount in the smallest currency unit. * `currency` (`string`) — ISO-4217. * `state` / `status` (`string`) — current dispute status as reported by the network (e.g. `PENDING`, `WON`, `LOST`). * `transfer` (`string`) — the Transfer ID this Dispute is contesting. * `reason_code` (`string`) — network-supplied reason (`fraud`, `product_not_received`, `duplicate`, etc.). Drives what evidence you should provide. * `respond_by` (`string`) — ISO deadline for submitting evidence. * `tags` (`Record`) — your own metadata. Mutable. For end-to-end response workflow, the Easy Labs dashboard is the primary surface — programmatic evidence upload is on the roadmap and not yet exposed via the public SDK. ## Related [#related] * [API reference](/docs/reference/api/payments/dispute) * [Guide: Handle a dispute](../guides/handle-dispute) * [Guide: Issue a refund](../guides/issue-refund) # Payments concepts (/docs/payments/concepts) The Payments surface is built around 6 core entities. Each page covers what the entity is, how its lifecycle progresses, and how it relates to the rest of the model. # Order (/docs/payments/concepts/order) An **Order** is the receipt-level record of a checkout: it ties a Customer, a Payment Instrument, the line items they purchased, the totals, and the resulting Transfer (the actual money movement) into a single object you can hand to your fulfillment, accounting, or analytics systems. Anything that goes through `client.checkout(...)`, an Embedded Checkout session, or a Payment Link produces an Order. Direct `createTransfer` calls do not — those are bare money movements without line-item context. ## Lifecycle [#lifecycle] 1. **Created** — emitted when a checkout flow runs successfully. The Order is created together with its child Transfer; both share the same `merchant_id`. 2. **Settled** — once the underlying Transfer's `state` becomes `SUCCEEDED`, the Order is the durable record you reconcile against. The `transfer` field on the Order embeds the Transfer object. 3. **Refunded** — when you call `client.createRefund(transferId, { refund_amount })` against the Order's Transfer, the reversal links back to the original Transfer (and therefore the Order). Refunds do not change the Order itself. 4. **Disputed** — if the buyer charges back, a `dispute.created` webhook fires. The Dispute references the Transfer; you can resolve back to the Order via `transfer.id`. Orders are immutable except for `tags`. Use `client.updateOrderTags(orderId, tags)` to add internal references (fulfillment ID, shipment number, etc.). ## Relationships [#relationships] Each Order has one **Customer** (`identity`), one **Payment Instrument** (`payment_instrument`), and at most one **Transfer** (`transfer`, may be `null` for orders that fail before authorization). Orders contain `purchase_items` which reference your **Products** and **Prices** by ID. Orders may carry a `payment_link_id` if they originated from a Payment Link. ## Fields that matter [#fields-that-matter] * `id`, `order_number` (`string`) — the canonical ID and a human-friendly receipt number safe to show buyers. * `subtotal_cents`, `tax_amount_cents`, `total_cents` (`number`) — amounts in the smallest currency unit. `total_cents` is what the buyer was charged. * `currency` (`string`) — ISO-4217 code. * `transfer` (`TransferData | null`) — the embedded Transfer. `transfer.state` tells you whether the money actually moved. * `purchase_items` (`PurchaseItemData[]`) — line items with quantity, price, and product snapshot (name + image at time of sale). * `buyer_details`, `shipping_address`, `billing_address` — what the buyer entered at checkout. Use these for fulfillment. * `tags` (`Record`) — your own metadata. Mutable. ## Related [#related] * [API reference](/docs/reference/api/payments/order) * [Guide: Embed hosted checkout](../guides/embed-hosted-checkout) * [Guide: Issue a refund](../guides/issue-refund) # Payment Instrument (/docs/payments/concepts/payment-instrument) A **Payment Instrument** is a saved, tokenized funding source attached to a Customer — a card or a bank account. It is what you reference as the `source` on a Transfer or as the funding side of a Subscription. The raw card or bank-account number never touches your servers: tokenization happens in the iframe / browser SDK, and the API stores only the network token, the last four digits, BIN, brand, expiration, and AVS metadata. ## Lifecycle [#lifecycle] 1. **Tokenized** — the buyer enters card or bank details inside the embedded checkout iframe (or the React tokenization helpers in `@easylabs/react`). Tokenization produces an opaque token ID. 2. **Created** — `client.createPaymentInstrument({ identityId, tokenId, type, … })` exchanges the token for a persistent Payment Instrument tied to the Customer. The response includes derived fields like `brand`, `last_four`, and `expiration_month` / `expiration_year`. 3. **Used** — pass the Payment Instrument `id` as the `source` on `client.createTransfer({ amount, currency, source })`, on `client.createCheckout(...)`, or as `instrument_id` on a Subscription. 4. **Updated** — `client.updatePaymentInstrument(id, { enabled, address, name, tags, … })` toggles enable state, refreshes the billing address, or patches AVS-related fields. 5. **Disabled** — set `enabled: false` to stop accepting new charges against the instrument; existing recurring subscriptions will start failing on next attempt. ## Relationships [#relationships] A Payment Instrument always belongs to one Customer (`identity_id`). One Customer can own many instruments — typically one default card plus older saved cards or bank accounts. Each Transfer references exactly one Payment Instrument as its `source`. Subscriptions reference one as `instrument_id` for recurring collection. ## Fields that matter [#fields-that-matter] * `id` (`string`) — pass as `source` on Transfers, as `instrument_id` on Subscriptions. * `type` (`"PAYMENT_CARD" | "BANK_ACCOUNT"`) — discriminates card vs. ACH; affects which fields are populated and which Transfer rails apply. * `enabled` (`boolean`) — soft toggle. `false` blocks new charges without deleting history. * `brand`, `last_four`, `expiration_month`, `expiration_year` — for displaying "Visa •••• 4242 (12/27)" in your UI. * `bin`, `card_type`, `issuer_country` — for routing decisions, fee surcharging, or geo-restricted offers. * `account_updater_enabled`, `network_token_enabled` — when on, the network refreshes the underlying credential on expiry / re-issuance so saved cards keep working. * `address` (`Address | null`) — billing address used for AVS. ## Related [#related] * [API reference](/docs/reference/api/payments/payment-instrument) * [Guide: Accept a card payment](../guides/accept-card-payment) * [Guide: Build a custom checkout](../guides/build-custom-checkout) # Session (/docs/payments/concepts/session) A **Session** in Payments refers to an **Embedded Checkout Session** — a short-lived, server-created handle that authorizes a single buyer to complete a single checkout inside the Easy Labs hosted iframe. You create the session on your backend with the merchant API key, hand the `client_secret` to your frontend, and the iframe authenticates against that secret instead of your API key. This is what keeps the merchant key off the public web while still letting you customize the surrounding page. ## Lifecycle [#lifecycle] 1. **Created** — `client.createEmbeddedCheckoutSession({ line_items, mode, success_url, cancel_url, customer_email, payment_methods })` returns a `EmbeddedCheckoutSessionData` with `id`, `client_secret`, `url`, `amount_total`, `currency`, `expires_at`, and an initial `status` of `open`. 2. **Open** — the buyer loads your page; you mount `` (React) or `mountEmbeddedCheckout("#root", { clientSecret })` (vanilla browser). The iframe validates the session against `/embedded-checkout/validate`. 3. **Confirmed** — the buyer enters payment details inside the iframe; the iframe POSTs to `/embedded-checkout/confirm`. On success the session's `status` transitions to `complete` and `payment_status` to `paid`. The `EmbeddedCheckoutProvider`'s `onSuccess` callback fires with `{ sessionId, status, tx_signature? }`. 4. **Closed** — sessions also reach a terminal state of `expired` when the `expires_at` window elapses without payment. Confirmed sessions cannot be re-used; create a new session for each retry. 5. **Webhook** — `checkout.session.completed` fires server-side when the session reaches `complete`. For crypto payments, `checkout.session.crypto_confirmed` fires when the on-chain transaction is confirmed. ## Relationships [#relationships] A Session is associated with one **Customer** (created or matched via `customer_email`), produces one **Order** on success, which produces one **Transfer**. The `line_items` reference your **Prices** by ID, which reference **Products**. For crypto payments the session optionally carries a `crypto_payment` block that resolves to a confirmed on-chain transaction (recorded against a **Wallet**). ## Fields that matter [#fields-that-matter] * `id` (`string`) — the session ID. Use with `getEmbeddedCheckoutSession` for server-side polling. * `client_secret` (`string`) — pass this to the browser; never log or persist it. Consumed by the iframe to authenticate. * `url` (`string`) — a fully hosted checkout URL. Use this if you want to redirect instead of embedding. * `status` (`"open" | "complete" | "expired"`) — coarse session state. * `payment_status` (`"unpaid" | "paid" | "no_payment_required"`) — payment-side state on the session-status endpoint. * `amount_total` (`number`), `currency` (`string`) — totals computed from `line_items`. * `expires_at` (`string`) — ISO timestamp after which the session is no longer usable. * `payment_methods` (`("card" | "crypto")[]`) — which methods the iframe will offer. ## Related [#related] * [API reference](/docs/reference/api/payments/embedded-checkout) * [Guide: Embed hosted checkout](../guides/embed-hosted-checkout) * [Guide: Build a custom checkout](../guides/build-custom-checkout) # Transfer (/docs/payments/concepts/transfer) A **Transfer** is the atomic record of a single money movement — a charge against a Payment Instrument, a refund (reversal), a settlement, or a fee. If you have used Stripe, this is the equivalent of a Charge plus a PaymentIntent collapsed into one object: there is no separate "intent" step, and the same shape covers debits, credits, refunds, and disputes via the `type` field. Most production integrations create Transfers indirectly through Checkout / Embedded Checkout / Subscriptions; the direct `client.createTransfer` API is for headless server-to-server charges against an already-saved Payment Instrument. ## Lifecycle [#lifecycle] 1. **Created** — `PENDING`. `client.createTransfer({ amount, currency, source })` (or any checkout flow) submits the charge to the processor. 2. **Authorized → captured** — the processor authorizes the funding source. For card transfers Easy Labs auto-captures; the state moves to `SUCCEEDED` when funds clear, or `FAILED` with a `failure_code` / `failure_message`. 3. **Settled** — once `ready_to_settle_at` is in the past, the Transfer is included in the next merchant Settlement. Funds appear in the merchant payout. 4. **Reversed (refunded)** — calling `client.createRefund(transferId, { refund_amount })` produces a child Transfer with `type: "REVERSAL"` and `parent_transfer` pointing back at the original. The original Transfer's `state` becomes `REVERSED` once fully reversed. 5. **Disputed** — if the buyer initiates a chargeback, a `dispute.created` webhook fires and a separate Transfer with `type: "DISPUTE"` is created to hold the disputed amount. Webhooks: `payment.created` and `payment.updated` track the canonical state transitions. `refund.created` / `refund.updated` fire on reversals. ## Relationships [#relationships] A Transfer has one `source` (a Payment Instrument ID), one `merchant_identity` (the Customer being charged), and optionally one `parent_transfer` (for reversals). Each Order embeds at most one Transfer. Disputes reference Transfers by ID. ## Fields that matter [#fields-that-matter] * `id` (`string`) — pass to `getTransfer`, `createRefund`, or as `transferId` for dispute lookups. * `amount`, `amount_requested`, `currency` — the captured amount, the originally requested amount, and ISO currency. Amounts are in the smallest unit (cents for USD). * `state` (`"PENDING" | "SUCCEEDED" | "FAILED" | "REVERSED" | "CANCELED" | "UNKNOWN"`) — terminal states are `SUCCEEDED`, `FAILED`, `REVERSED`, `CANCELED`. * `type` (`"DEBIT" | "CREDIT" | "REVERSAL" | "DISPUTE" | "FEE" | "ADJUSTMENT" | "RESERVE" | "SETTLEMENT" | "UNKNOWN"`) — what kind of money movement this is. Filter on `type === "DEBIT"` for buyer charges. * `failure_code`, `failure_message` — populated when `state === "FAILED"`. Surface these to the buyer for retry-friendly errors (insufficient funds, declined, etc.). * `parent_transfer` (`string | null`) — set on reversals to the original Transfer's ID. * `fee` (`number`) — Easy Labs / network fee for this Transfer in the smallest unit. * `ready_to_settle_at` (`string`) — ISO timestamp when this Transfer is eligible for the next settlement. ## Related [#related] * [API reference](/docs/reference/api/payments/transfer) * [Guide: Accept a card payment](../guides/accept-card-payment) * [Guide: Issue a refund](../guides/issue-refund) # Accept a card payment (/docs/payments/guides/accept-card-payment) ## Goal [#goal] Charge an existing Customer's saved card for an arbitrary amount, without rendering a checkout UI. This is the right pattern for headless flows: post-purchase upsells, scheduled jobs that bill on your own cadence, retry of a failed payment, or any backend that already knows which Customer + Payment Instrument to charge. If you do not yet have a saved Payment Instrument for the Customer, see [Embed hosted checkout](./embed-hosted-checkout) first — that flow tokenizes the card safely and saves the instrument for re-use. ## Prerequisites [#prerequisites] * Easy Labs API key (sandbox or production) — see [Quickstart](../quickstart). * `@easylabs/node` installed. * A `customerId` for the buyer (returned by `createCustomer`). * A Payment Instrument `id` belonging to that Customer (returned by `createPaymentInstrument`, or fetched via `getCustomerPaymentInstruments`). ## Implementation [#implementation] ### 1. Initialize the client [#1-initialize-the-client] ```ts import { createClient } from "@easylabs/node"; const easy = await createClient({ apiKey: process.env.EASY_API_KEY! }); ``` `createClient` validates the key against the Easy Labs API before resolving, so a misconfigured environment fails fast at startup rather than on first charge. ### 2. Look up (or pick) the Payment Instrument [#2-look-up-or-pick-the-payment-instrument] ```ts const { data: instruments } = await easy.getCustomerPaymentInstruments( customerId, ); const card = instruments.find((i) => i.type === "PAYMENT_CARD" && i.enabled); if (!card) throw new Error("No active card on file"); ``` In production you typically store the Payment Instrument `id` on your own customer record at the time of save, instead of re-fetching it on every charge. ### 3. Create the Transfer [#3-create-the-transfer] ```ts const { data: transfer } = await easy.createTransfer({ amount: 4999, // $49.99 in cents currency: "USD", source: card.id, tags: { internal_order_id: "order_123" }, }); if (transfer.state === "FAILED") { // Surface a buyer-friendly retry path. throw new Error(transfer.failure_message ?? "Payment failed"); } ``` A `Transfer` with `state: "SUCCEEDED"` is a captured charge — funds will appear in the next merchant Settlement once `ready_to_settle_at` passes. `state: "PENDING"` means the processor is still working; subscribe to the `payment.updated` webhook to be notified of the terminal state without polling. ### 4. (Optional) React to webhooks [#4-optional-react-to-webhooks] If you want server-side confirmation rather than relying on the synchronous response, register a webhook endpoint and verify deliveries with `EasyWebhooks.constructEvent`: ```ts import { EasyWebhooks } from "@easylabs/node"; app.post("/webhooks/easy", async (req, res) => { const event = EasyWebhooks.constructEvent( req.rawBody, req.header("x-easy-webhook-signature") ?? "", process.env.EASY_WEBHOOK_SECRET!, ); if (event.type === "payment.updated") { // event.data is the Transfer } res.status(204).end(); }); ``` ## Tradeoffs [#tradeoffs] * This pattern requires that the buyer has previously consented to save a card with you. For the first charge, use [Embed hosted checkout](./embed-hosted-checkout) — it tokenizes safely and returns a re-usable instrument id. * Direct Transfers do not produce an [Order](../concepts/order). If you need line-item bookkeeping, use `client.checkout({ … })` instead, which produces both a Transfer and an Order with line items. * Refunds are issued against the Transfer ID — not the Customer or instrument. See [Issue a refund](./issue-refund). ## Related [#related] * [Concept: Transfer](../concepts/transfer) * [Concept: Payment Instrument](../concepts/payment-instrument) * [API reference](/docs/reference/api/payments/transfer) # Accept a wallet payment (/docs/payments/guides/accept-wallet-payment) ## Goal [#goal] Accept payment in stablecoins from a buyer's self-custodial wallet (currently Solana / USDC) and reconcile the on-chain transaction back to an Order in your dashboard. Wallet payments are exposed through the same Embedded Checkout surface as card payments — you opt into them by including `"crypto"` in the session's `payment_methods`. The iframe handles wallet connection, message signing, and broadcasting the transaction; your code listens for the confirmed-on-chain event. ## Prerequisites [#prerequisites] * Easy Labs API key with crypto enabled on your account — see [Quickstart](../quickstart). * `@easylabs/node` (server) and `@easylabs/react` or `@easylabs/browser` (frontend) installed. * Your origin added to `allowed_origins` via `client.updateEmbeddedCheckoutConfig`. ## Implementation [#implementation] ### 1. Create a session that allows crypto [#1-create-a-session-that-allows-crypto] ```ts import { createClient } from "@easylabs/node"; const easy = await createClient({ apiKey: process.env.EASY_API_KEY! }); const { data: session } = await easy.createEmbeddedCheckoutSession({ mode: "payment", line_items: [{ price_id: "price_01HXXXXXXXXXXX", quantity: 1 }], success_url: "https://your-app.com/checkout/success", cancel_url: "https://your-app.com/checkout/cancel", payment_methods: ["card", "crypto"], // both, or ["crypto"] only }); ``` Pass `["crypto"]` alone to force the wallet flow; pass both to let the buyer pick. The session's response carries a `crypto_payment` block with the destination address, USDC amount, and Solana Pay URL the iframe will use. ### 2. Mount the iframe and listen for confirmation [#2-mount-the-iframe-and-listen-for-confirmation] In React, `EmbeddedCheckoutProvider`'s `onSuccess` callback fires for both card and crypto completions. The crypto-specific event includes the on-chain transaction signature: ```tsx "use client"; import { EmbeddedCheckout, EmbeddedCheckoutProvider } from "@easylabs/react"; export function CryptoCheckout({ clientSecret }: { clientSecret: string }) { return ( { // For crypto payments, tx_signature is the Solana transaction signature. console.log("paid", { sessionId, status, tx_signature }); }, }} > ); } ``` In vanilla JS: ```ts import { mountEmbeddedCheckout } from "@easylabs/browser"; const handle = mountEmbeddedCheckout("#checkout", { clientSecret, onCryptoConfirmed: (data) => { console.log("crypto confirmed", data); }, }); ``` ### 3. (Optional) Poll the chain status server-side [#3-optional-poll-the-chain-status-server-side] If the buyer leaves the page before the iframe reports confirmation, you can poll the session's crypto status from your backend: ```ts const { data: cryptoStatus } = await easy.getCryptoPaymentStatus(session.id); // cryptoStatus.status: "pending" | "confirmed" | "expired" | "failed" // cryptoStatus.tx_signature: the on-chain signature once confirmed ``` The canonical signal is still the `checkout.session.crypto_confirmed` webhook — register it and use it to fulfill orders without depending on the browser staying open. ## Tradeoffs [#tradeoffs] * Crypto payments are USDC-only on Solana today. Other chains and tokens are on the roadmap. * The iframe handles wallet connection — you do not need to bundle a wallet adapter or `@solana/web3.js` in your app. * Refunds for crypto transactions are not automatic; they require an off-chain reconciliation. Open a support ticket if you need to refund a confirmed crypto Order. * `tx_signature` is the buyer's proof of payment on-chain. Store it alongside your Order for audit / customer service. ## Related [#related] * [Concept: Session](../concepts/session) * [Concept: Order](../concepts/order) * [API reference](/docs/reference/api/payments/embedded-checkout) # Build a custom checkout (/docs/payments/guides/build-custom-checkout) ## Goal [#goal] Build a checkout where you own the surrounding page composition, multi-step navigation, and post-payment logic, while delegating the actual card / bank-account entry to a securely hosted surface. The pattern: create the checkout session on your server, pass the `client_secret` to the browser, mount the iframe inside your own multi-step UI, and react to the iframe's lifecycle events. For most teams this is the right escape valve when [Embed hosted checkout](./embed-hosted-checkout) feels too constraining but going all the way to raw card tokenization is more compliance scope than you want to take on. ## Prerequisites [#prerequisites] * Easy Labs API key — see [Quickstart](../quickstart). * `@easylabs/node` (server) and `@easylabs/react` or `@easylabs/browser` (frontend) installed. * Origin added to `allowed_origins` via `client.updateEmbeddedCheckoutConfig`. * A frontend that can manage multi-step state (your own framework / store). ## Implementation [#implementation] ### 1. Server: create the session with your own line items [#1-server-create-the-session-with-your-own-line-items] ```ts import { createClient } from "@easylabs/node"; const easy = await createClient({ apiKey: process.env.EASY_API_KEY! }); export async function POST(req: Request) { const { cart, buyerEmail } = (await req.json()) as { cart: Array<{ priceId: string; quantity: number }>; buyerEmail: string; }; const { data: session } = await easy.createEmbeddedCheckoutSession({ mode: "payment", customer_email: buyerEmail, line_items: cart.map((i) => ({ price_id: i.priceId, quantity: i.quantity })), success_url: "https://your-app.com/checkout/success", cancel_url: "https://your-app.com/checkout/cancel", metadata: { source: "custom_checkout_v2" }, payment_methods: ["card"], }); return Response.json({ clientSecret: session.client_secret, sessionId: session.id, amountTotal: session.amount_total, currency: session.currency, }); } ``` ### 2. Frontend: orchestrate your own steps, mount the iframe at the payment step [#2-frontend-orchestrate-your-own-steps-mount-the-iframe-at-the-payment-step] ```tsx "use client"; import { EmbeddedCheckout, EmbeddedCheckoutProvider, useEmbeddedCheckout, } from "@easylabs/react"; import { useState } from "react"; type Step = "review" | "payment" | "done"; export function CustomCheckout({ clientSecret }: { clientSecret: string }) { const [step, setStep] = useState("review"); if (step === "review") { return ( setStep("payment")} /> ); } if (step === "payment") { return ( setStep("done"), onClose: () => setStep("review"), onError: (err) => alert(err), }} > ); } return ; } function CheckoutShell() { const { status } = useEmbeddedCheckout(); return (
{status === "loading" &&

Preparing payment…

}
); } ``` `useEmbeddedCheckout()` exposes the current `status` (`"loading" | "ready" | "complete" | "error"`) so you can render skeletons, disable a parent "Pay now" CTA, or trigger analytics events as the iframe's lifecycle progresses. ### 3. Server: confirm and fulfill on the webhook [#3-server-confirm-and-fulfill-on-the-webhook] The browser callback is convenient for UI transitions but is not the source of truth. Use the `checkout.session.completed` webhook to fulfill the order: ```ts import { EasyWebhooks } from "@easylabs/node"; app.post("/webhooks/easy", async (req, res) => { const event = EasyWebhooks.constructEvent( req.rawBody, req.header("x-easy-webhook-signature") ?? "", process.env.EASY_WEBHOOK_SECRET!, ); if (event.type === "checkout.session.completed") { // Look up your internal cart by session.metadata, then fulfill. } res.status(204).end(); }); ``` ## Tradeoffs [#tradeoffs] * The iframe still renders the actual payment fields — you own the page chrome and step navigation but not the form layout. If you need a fully custom card form rendered with your own components, contact your account team about the white-label tokenization SDK; it carries additional PCI scope. * One session = one payment attempt. If the buyer changes their cart between steps, create a new session. Cache the session at the cart-hash level to avoid creating one per render. * `useEmbeddedCheckout` is a React hook; in vanilla JS the equivalent signal is the `onReady` callback on `mountEmbeddedCheckout`. ## Related [#related] * [Concept: Session](../concepts/session) * [Guide: Embed hosted checkout](./embed-hosted-checkout) * [API reference](/docs/reference/api/payments/embedded-checkout) # Embed hosted checkout (/docs/payments/guides/embed-hosted-checkout) ## Goal [#goal] Render the Easy Labs checkout inside your own page so the buyer never leaves your site, while keeping all card / bank entry inside the hosted iframe (which means the merchant API key never reaches the browser, and your servers never touch raw PAN data). This is the recommended default for accepting first-time payments. ## Prerequisites [#prerequisites] * Easy Labs API key — see [Quickstart](../quickstart). * `@easylabs/node` for the server, `@easylabs/react` (or `@easylabs/browser` for vanilla JS) for the browser. * At least one published Product + Price you can charge for. Create them in the dashboard or with `client.createProduct` / `client.createPrice`. * Your site's origin added to the merchant's `allowed_origins` config. Set it once with `client.updateEmbeddedCheckoutConfig({ allowed_origins: ["https://your-app.com"] })`. ## Implementation [#implementation] ### 1. Create a session on the server [#1-create-a-session-on-the-server] ```ts import { createClient } from "@easylabs/node"; const easy = await createClient({ apiKey: process.env.EASY_API_KEY! }); export async function POST() { const { data: session } = await easy.createEmbeddedCheckoutSession({ mode: "payment", line_items: [{ price_id: "price_01HXXXXXXXXXXX", quantity: 1 }], success_url: "https://your-app.com/checkout/success", cancel_url: "https://your-app.com/checkout/cancel", customer_email: "ada@example.com", payment_methods: ["card"], }); return Response.json({ clientSecret: session.client_secret }); } ``` `session.client_secret` authenticates the iframe against the session — it is safe to send to the browser. The merchant API key stays on the server. ### 2. Mount the iframe in the browser [#2-mount-the-iframe-in-the-browser] With React: ```tsx "use client"; import { EmbeddedCheckout, EmbeddedCheckoutProvider } from "@easylabs/react"; import { useEffect, useState } from "react"; export function Checkout() { const [clientSecret, setClientSecret] = useState(null); useEffect(() => { fetch("/api/checkout-session", { method: "POST" }) .then((r) => r.json()) .then(({ clientSecret }) => setClientSecret(clientSecret)); }, []); if (!clientSecret) return

Loading checkout…

; return ( { window.location.href = `/checkout/success?session=${sessionId}`; }, onError: (err) => console.error("checkout error", err), onClose: () => console.log("buyer closed checkout"), }} > ); } ``` With vanilla JS / `@easylabs/browser`: ```ts import { mountEmbeddedCheckout } from "@easylabs/browser"; const handle = mountEmbeddedCheckout("#checkout", { clientSecret, onReady: () => console.log("checkout ready"), }); // later — destroy when navigating away: handle.unmount(); ``` ### 3. Confirm on the server (recommended) [#3-confirm-on-the-server-recommended] Don't trust the browser's `onSuccess` alone — confirm the session server-side before fulfilling, either by handling the `checkout.session.completed` webhook or by reading the session status: ```ts import { EasyWebhooks } from "@easylabs/node"; app.post("/webhooks/easy", async (req, res) => { const event = EasyWebhooks.constructEvent( req.rawBody, req.header("x-easy-webhook-signature") ?? "", process.env.EASY_WEBHOOK_SECRET!, ); if (event.type === "checkout.session.completed") { // event.data is the completed session — fulfill the order here. } res.status(204).end(); }); ``` ## Tradeoffs [#tradeoffs] * The iframe owns the look and feel within its bounds — you control the surrounding page, theme color, and logo (set via the merchant branding API), but the form layout itself is managed. * For a fully bespoke UI where you render your own card form, see [Build a custom checkout](./build-custom-checkout). * Sessions are single-use. If the buyer abandons and comes back, create a new session. * The iframe's allowed-origin list is a hard security boundary. Forgetting to add a new domain results in a `validate` failure inside the iframe with no charge attempted. ## Related [#related] * [Concept: Session](../concepts/session) * [Concept: Order](../concepts/order) * [API reference](/docs/reference/api/payments/embedded-checkout) # Handle a dispute (/docs/payments/guides/handle-dispute) ## Goal [#goal] Build the operational glue around chargebacks: fire an alert when a Dispute opens, surface it in your internal tooling alongside the original Order, and update your records when the network rules. Most evidence collection happens through the dashboard; the SDK's role is detection, correlation, and bookkeeping. ## Prerequisites [#prerequisites] * Easy Labs API key — see [Quickstart](../quickstart). * `@easylabs/node` installed. * A registered webhook endpoint that subscribes to `dispute.created` and `dispute.updated`. Register one with `client.registerWebhookEndpoint({ url, events: ["dispute.created", "dispute.updated"] })` and store the returned signing secret immediately — it is only ever returned once. ## Implementation [#implementation] ### 1. Listen for new disputes [#1-listen-for-new-disputes] ```ts import { createClient, EasyWebhooks } from "@easylabs/node"; const easy = await createClient({ apiKey: process.env.EASY_API_KEY! }); app.post("/webhooks/easy", async (req, res) => { const event = EasyWebhooks.constructEvent( req.rawBody, req.header("x-easy-webhook-signature") ?? "", process.env.EASY_WEBHOOK_SECRET!, ); if (event.type === "dispute.created") { const dispute = event.data as { id: string; transfer: string }; await alertOpsTeam({ disputeId: dispute.id, transferId: dispute.transfer, }); } if (event.type === "dispute.updated") { const dispute = event.data as { id: string; status: string }; await updateInternalRecord(dispute.id, dispute.status); } res.status(204).end(); }); ``` `EasyWebhooks.constructEvent` verifies the HMAC-SHA256 signature against the raw request body. **Do not parse the body before passing it in** — JSON re-stringifying changes whitespace and breaks the signature check. ### 2. Correlate the Dispute back to your Order [#2-correlate-the-dispute-back-to-your-order] The Dispute's `transfer` field is the Transfer ID; from there you can resolve the Order: ```ts const { data: dispute } = await easy.getDispute(disputeId); const transferId = dispute.transfer as string; const { data: transfer } = await easy.getTransfer(transferId); // In production, look up your internal order using transfer.tags.internal_order_id // or by querying your DB for the matching Customer + amount + timestamp. ``` If you set `tags` on the Transfer or Order at creation time (e.g. `tags: { internal_order_id }`), this lookup is a single DB query. ### 3. Tag the Dispute for internal triage [#3-tag-the-dispute-for-internal-triage] You can attach your own metadata to the Dispute so it correlates with your internal ticketing system: ```ts await easy.updateDispute(disputeId, { internal_case_id: "CASE-2026-00123", assigned_to: "ops-team", }); ``` `tags` are the only mutable surface on a Dispute — submitting evidence happens through the dashboard. ### 4. Resolve [#4-resolve] When `dispute.updated` fires with a terminal status (`WON`, `LOST`, `ACCEPTED`), update your books: * `WON` — funds return to the merchant settlement; reverse any provisional refund you issued. * `LOST` — funds remain with the buyer; write off the loss against the original Order. * `ACCEPTED` — you chose not to contest; same financial impact as `LOST`. ## Tradeoffs [#tradeoffs] * The SDK does not currently expose programmatic evidence upload — that workflow lives in the dashboard. If you need API-driven evidence for a custom internal tool, talk to your account team. * The Dispute schema is processor-permissive (`{ id: string; [key: string]: unknown }`) — fields beyond `id`, `amount`, `currency`, `status`, `transfer`, `reason_code`, and `respond_by` may vary across networks. Treat unknown fields defensively. * Always rely on `dispute.updated` for terminal status. Polling `getDispute` works but wastes API budget. ## Related [#related] * [Concept: Dispute](../concepts/dispute) * [Concept: Transfer](../concepts/transfer) * [API reference](/docs/reference/api/payments/dispute) # Payments guides (/docs/payments/guides) Step-by-step recipes for the most common Payments integrations. Each guide states the goal, lists prerequisites, and ships working code. # Issue a refund (/docs/payments/guides/issue-refund) ## Goal [#goal] Refund a successful charge back to the original payment method, in part or in full. Refunds in Easy Labs are first-class Transfers themselves (with `type: "REVERSAL"`) — they link to the original via `parent_transfer`, fire their own webhooks, and settle on their own timeline. This guide covers the most common cases: full refund of a recent charge, partial refund (e.g. one returned line item), and detecting that a refund has finished. ## Prerequisites [#prerequisites] * Easy Labs API key — see [Quickstart](../quickstart). * `@easylabs/node` installed. * The `transferId` of the original successful charge. From an Order, this is `order.transfer.id`. ## Implementation [#implementation] ### 1. Issue a full refund [#1-issue-a-full-refund] ```ts import { createClient } from "@easylabs/node"; const easy = await createClient({ apiKey: process.env.EASY_API_KEY! }); const { data: original } = await easy.getTransfer(transferId); const { data: refund } = await easy.createRefund(transferId, { refund_amount: original.amount, // full amount, in the smallest currency unit }); // refund.id is the new reversal Transfer. // refund.parent_transfer === transferId ``` The reversal is created in the `PENDING` state and progresses to `SUCCEEDED` once the issuer accepts it. The original Transfer's state moves to `REVERSED` once fully reversed. ### 2. Issue a partial refund [#2-issue-a-partial-refund] ```ts const { data: partial } = await easy.createRefund(transferId, { refund_amount: 1000, // $10.00 of the original $50.00 charge tags: { reason: "returned_one_item", internal_rma_id: "RMA-789" }, }); ``` You can issue multiple partial refunds against the same original Transfer up to the original amount. After the sum of reversals equals the original `amount`, the original transitions to `REVERSED`. ### 3. (Optional) Subscribe to the refund webhook [#3-optional-subscribe-to-the-refund-webhook] If you want server-side confirmation rather than polling, subscribe to `refund.created` and `refund.updated`: ```ts import { EasyWebhooks } from "@easylabs/node"; app.post("/webhooks/easy", async (req, res) => { const event = EasyWebhooks.constructEvent( req.rawBody, req.header("x-easy-webhook-signature") ?? "", process.env.EASY_WEBHOOK_SECRET!, ); if (event.type === "refund.updated") { // event.data is the reversal Transfer } res.status(204).end(); }); ``` ### 4. Verify in the dashboard [#4-verify-in-the-dashboard] Open the original Order in the [Easy Labs dashboard](https://dashboard.itseasy.co) — you will see the linked reversal under "Refunds" with its own state and amount. ## Tradeoffs [#tradeoffs] * Refunds always go back to the original Payment Instrument; you cannot re-route a refund to a different card or to a wallet. * Crypto Orders are not refundable through `createRefund`. For confirmed on-chain payments, open a support ticket and reconcile off-chain. * Refunds fire `refund.created` / `refund.updated` events, NOT `payment.updated` events. Wire up the right handler. * A failed refund (issuer rejects) sets the reversal's `state` to `FAILED`; the original Transfer is unchanged. Surface the `failure_message` to your ops team. ## Related [#related] * [Concept: Transfer](../concepts/transfer) * [Concept: Order](../concepts/order) * [API reference](/docs/reference/api/payments/transfer) # Migrate from Stripe to Easy Payments (/docs/payments/migration/from-stripe) This page is the mechanical method-and-event mapping between the Stripe Node SDK and `@easylabs/node`. Field names mostly carry over; the larger conceptual shift is that Easy Labs collapses Stripe's PaymentIntent / Charge / SetupIntent split into a single `Transfer` lifecycle. See **Object model differences** at the bottom. ## API surface mapping [#api-surface-mapping] | Stripe | Easy Labs | Notes | | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | `stripe.customers.create({ email, name })` | `easy.createCustomer({ first_name, last_name, email })` | First/last name are required and split into separate fields. | | `stripe.customers.retrieve(id)` | `easy.getCustomer(id)` | Identical. | | `stripe.customers.update(id, …)` | `easy.updateCustomer(id, partial)` | Identical. | | `stripe.customers.list({ limit })` | `easy.getCustomers({ limit, offset })` | Pagination is `offset`-based, not cursor-based. | | `stripe.customers.listPaymentMethods(id)` | `easy.getCustomerPaymentInstruments(id)` | Returns Payment Instruments instead of PaymentMethods. | | `stripe.paymentMethods.create({ type: "card" })` | Use `mountEmbeddedCheckout` (`@easylabs/browser`) or `` (`@easylabs/react`); the iframe tokenizes and creates the Payment Instrument. | No raw card → token call on the server. | | `stripe.paymentMethods.retrieve(id)` | Available implicitly via `easy.getCustomerPaymentInstruments(customerId).find(i => i.id === pmId)` | No standalone get-by-id endpoint. | | `stripe.paymentMethods.update(id, …)` | `easy.updatePaymentInstrument(id, partial)` | Includes `enabled`, `address`, `tags`. | | `stripe.paymentIntents.create({ amount, … })` | `easy.createTransfer({ amount, currency, source })` | No two-step intent → confirm. Authorization + capture happen in one call against a Payment Instrument. | | `stripe.paymentIntents.confirm(id)` | n/a | Implicit; `createTransfer` is the equivalent of "create + confirm". | | `stripe.charges.create({ … })` | `easy.createTransfer({ amount, currency, source })` | Renamed; same contract. | | `stripe.charges.retrieve(id)` | `easy.getTransfer(id)` | Identical. | | `stripe.charges.list({ limit })` | `easy.getTransfers({ limit, offset })` | Pagination differences as above. | | `stripe.refunds.create({ charge, amount })` | `easy.createRefund(transferId, { refund_amount })` | Reversal is itself a Transfer with `type: "REVERSAL"`. | | `stripe.disputes.retrieve(id)` | `easy.getDispute(id)` | Identical. | | `stripe.disputes.list()` | `easy.getDisputes({ limit, offset })` | Pagination differences. | | `stripe.disputes.update(id, { metadata })` | `easy.updateDispute(id, tags)` | Tags are the only mutable surface; evidence upload is dashboard-only today. | | `stripe.checkout.sessions.create({ … })` | `easy.createEmbeddedCheckoutSession({ … })` | `success_url` / `cancel_url` / `line_items` shapes carry over. Add `payment_methods: ["card"]` (or `"crypto"`). | | `stripe.checkout.sessions.retrieve(id)` | `easy.getEmbeddedCheckoutSession(id)` | Identical. | | `stripe.products.create(…)` / `.update` / `.list` | `easy.createProduct` / `.updateProduct` / `.getProducts` | Identical surface. | | `stripe.prices.create(…)` / `.update` / `.list` | `easy.createPrice` / `.updatePrice` / `.getPrices` | `unit_amount` is in the smallest currency unit, same as Stripe. | | `stripe.subscriptions.create({ customer, items })` | `easy.createSubscription({ identity_id, items, instrument_id })` | `customer` → `identity_id`. Funding source must be passed explicitly. | | `stripe.subscriptions.cancel(id)` | `easy.cancelSubscription(id, { at_period_end: true })` | Default cancels immediately; opt into period-end via flag. | | `stripe.invoices.create(…)` | `easy.createInvoice(…)` | Field names differ; see invoice reference. | | `stripe.webhookEndpoints.create(…)` | `easy.registerWebhookEndpoint({ url, events })` | Returns the signing secret **once**; persist immediately. | | `stripe.webhooks.constructEvent(body, sig, sec)` | `EasyWebhooks.constructEvent(rawBody, sig, secret)` | Identical signature. Header is `x-easy-webhook-signature`. | ## Webhook event mapping [#webhook-event-mapping] | Stripe event | Easy Labs event | Notes | | -------------------------------------- | ------------------------------------- | ----------------------------------------------------------------------- | | `payment_intent.succeeded` | `payment.updated` (state `SUCCEEDED`) | Easy Labs collapses intent + charge events into one `payment.*` stream. | | `payment_intent.payment_failed` | `payment.updated` (state `FAILED`) | `failure_code` + `failure_message` are populated on the Transfer. | | `charge.succeeded` | `payment.created` / `payment.updated` | The single Transfer lifecycle covers both. | | `charge.refunded` | `refund.updated` (state `SUCCEEDED`) | The reversal is its own Transfer event. | | `charge.refund.updated` | `refund.updated` | Identical semantics. | | `charge.dispute.created` | `dispute.created` | Identical. | | `charge.dispute.closed` | `dispute.updated` (terminal status) | Watch for `status` transitioning to `WON` / `LOST`. | | `checkout.session.completed` | `checkout.session.completed` | Identical name. | | `customer.subscription.created` | `subscription.created` | Identical. | | `customer.subscription.updated` | `subscription.updated` | Identical. | | `customer.subscription.deleted` | `subscription.deleted` | Identical. | | `customer.subscription.paused` | `subscription.paused` | Identical. | | `customer.subscription.resumed` | `subscription.resumed` | Identical. | | `customer.subscription.trial_will_end` | `subscription.trial_will_end` | Identical. | | `invoice.created` | `invoice.created` | Identical. | | `invoice.finalized` | `invoice.finalized` | Identical. | | `invoice.paid` | `invoice.paid` | Identical. | | `invoice.payment_failed` | `invoice.payment_failed` | Identical. | | `customer.created` | `identity.created` | "Identity" is the underlying processor entity for Customer. | | `customer.updated` | `identity.updated` | Identical semantics. | ## Object model differences [#object-model-differences] * **No PaymentIntent.** Stripe separates "intent to charge" from "actual charge"; Easy Labs has one `Transfer` lifecycle that covers both (`PENDING → SUCCEEDED | FAILED`). If you used `PaymentIntent.id` to correlate before/after capture, switch to `Transfer.id`. * **Identity vs. Customer.** Internally Easy Labs calls customers "identities" (you'll see `identity_id` on Subscriptions, `identity` on Orders, and `identity.*` webhook events). The SDK surface still uses `customer` everywhere — `identity_id` is the field name on payloads. * **Payment Instruments are saved by default.** A successful checkout always produces a re-usable `Payment Instrument`. There is no separate SetupIntent for "save card without charging" — instead, create a small auth + void or save during a real first payment. * **Order is a first-class object.** Stripe doesn't have one — it conflates "the transaction" with "the line items." Easy Labs separates Order (line items, totals, customer details) from Transfer (money movement), so refunds happen against the Transfer but fulfillment data lives on the Order. * **Pagination is offset-based.** No `starting_after` / `ending_before` cursors. Pass `limit` + `offset`. * **Webhook signature header.** Stripe uses `Stripe-Signature` with a `t=…,v1=…` payload; Easy Labs uses `x-easy-webhook-signature: sha256=` over the raw body. The Node verifier handles this for you (`EasyWebhooks.constructEvent`). # Migrate to Easy Payments (/docs/payments/migration) Mechanical mappings from competing APIs to Easy Payments. Each migration page covers method-by-method translation, webhook event pairs, and meaningful conceptual differences. # API (/docs/reference/api) The Easy HTTP API is split into four products. Each operation lives under its product's section in the sidebar — pick the surface you're integrating with. Looking for SDK code samples instead? See the [SDK reference](/docs/sdks). # Client (/docs/sdks/javascript/client) `createEasyClient` returns an `EasyBrowserClient` — the typed `EasyApiClient` from `@easylabs/common` decorated with browser-only helpers. One client wraps one API key. Construct it once at app startup and pass it around (via your DI container, a module-level singleton, or your framework's context primitive). The factory validates the supplied key against `GET /validate-key` before resolving, so a successful `await` is your guarantee that the key works against the resolved environment. ## Constructor [#constructor] ```ts import { createEasyClient } from "@easylabs/browser"; const easy = await createEasyClient({ apiKey: "sk_test_...", }); ``` ### Options [#options] | Option | Type | Required | Description | | -------------------- | --------- | -------- | --------------------------------------------------------------------------------------------------------------------------------- | | `apiKey` | `string` | yes | Your Easy Labs secret key. Keys prefixed `sk_test_` route to the sandbox environment; everything else routes to production. | | `__internal_api_url` | `string` | no | **Internal.** Override the resolved API base URL. Used for tests and local development. Public consumers should leave this unset. | | `__dev` | `boolean` | no | **Internal, deprecated.** No-op. Kept on the type for source compatibility and will be removed in a future major. | ### Throws [#throws] * `Error("apiKey is required to create an Easy Labs browser client.")` if `apiKey` is empty. * `Error("API key validation failed: ...")` if the key is rejected by `/validate-key`. * A network error if the API is unreachable. ### Direct import [#direct-import] If you only need the embedded-checkout helper, you don't have to construct a client at all — `mountEmbeddedCheckout` is a tree-shakeable named export: ```ts import { mountEmbeddedCheckout } from "@easylabs/browser"; ``` It authenticates against the session's `client_secret`, not your API key. The [Element factories](./elements) (`mountCardNumberElement`, etc.) and the [wallet button factories](./wallet-checkout) (`createApplePayButton`, `createGooglePayButton`) are also tree-shakeable named exports. Element factories require an initialised Basis Theory client — call `createEasyClient` first, or use `configureBasisTheoryFromKey(apiKey, apiUrl)` for a lighter bootstrap when you don't need the typed `EasyApiClient` surface. ## Core methods [#core-methods] The returned client exposes the full `EasyApiClient` surface from `@easylabs/common`. A representative slice — these are all methods you call **on the client instance** (`easy.createCustomer(...)`): | Method | Purpose | | -------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | | `createCustomer(data)` / `getCustomer(id)` / `getCustomers(params)` | Customer CRUD. | | `createEmbeddedCheckoutSession(data)` | Mint a session server-side. **Do not call from the browser** — exposes your secret key. | | `getEmbeddedCheckoutSession(id)` | Fetch session status (open / complete / expired). | | `validateEmbeddedCheckoutSession(body)` / `confirmEmbeddedCheckoutSession(body)` | Iframe-context endpoints. Authenticate via `client_secret`, not the API key. | | `getCryptoPaymentStatus(sessionId)` | Poll a session's crypto payment state. | | `createPaymentLink(payload)` / `getPaymentLinks(params)` / `getPaymentLink(id)` | Payment-link CRUD. | | `createPaymentInstrument(data)` / `updatePaymentInstrument(id, data)` | Payment instrument management. | | `getProducts(params)` | List products. | | `listInvoices(query)` | List invoices with filters. | For the complete surface, refer to the type definitions exported from `@easylabs/common` (re-exported from `@easylabs/browser`). Every method returns a typed `ApiResponse` and throws `EasyApiError` on non-2xx responses. ### Related exports [#related-exports] These are **standalone factory functions** exported from `@easylabs/browser` — they are *not* methods on the client returned by `createEasyClient`. Import them directly and call them with their own arguments: | Export | Purpose | | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `mountEmbeddedCheckout(container, options)` | Mount the hosted-checkout iframe. Authenticates against the session's `client_secret`, so no client construction is needed. See [Embedded Checkout](./embedded-checkout). | | `mountCardNumberElement` / `mountCardExpirationDateElement` / `mountCardVerificationCodeElement` / `mountTextElement` | Mount iframed PCI-isolated form fields. Require an initialised Basis Theory client (see below). See [Elements](./elements). | | `tokenize({ cardNumber, cardExpiration, cardCvc })` | Exchange three mounted card elements for a Basis Theory token reference. | | `createApplePayButton` / `createGooglePayButton` | Render native wallet buttons. See [Wallet Checkout](./wallet-checkout). | | `configureBasisTheoryFromKey(apiKey, apiUrl?)` | Lighter bootstrap for Element factories when you don't need the typed `EasyApiClient` surface. `createEasyClient` already runs this for you. | Element factories require Basis Theory to be initialised first — call `createEasyClient` (which initialises it as part of bootstrap) or `configureBasisTheoryFromKey` before mounting any element. ### Errors [#errors] Every API call rejects with an `EasyApiError` carrying the HTTP status, the API error `code`, and the parsed `details` payload: ```ts import { EasyApiError } from "@easylabs/browser"; try { await easy.cancelSubscription("sub_123"); } catch (err) { if (err instanceof EasyApiError && err.status === 429) { await new Promise((r) => setTimeout(r, (err.retryAfterSeconds ?? 1) * 1000), ); } else { throw err; } } ``` ## Lifecycle [#lifecycle] The client holds no long-lived resources — no sockets, no timers, no listeners. It's a thin façade over `fetch` plus an in-memory copy of your API key. You can construct it as many times as you want, but in practice one instance per app is the right shape. There is no `dispose` or `close`. To "tear it down", drop your reference and let the garbage collector handle it. `mountEmbeddedCheckout` **does** create resources (a `