---
name: sezzle-checkout
description: >
  Implement a full Sezzle BNPL checkout integration from scratch — authentication,
  session creation, shopper redirect, and order management (capture, refund, release,
  reauthorize, upcharge). Covers direct REST API and the JavaScript Checkout SDK
  (popup, iframe, redirect), plus express checkout. Use when: "add Sezzle to my
  checkout", "integrate Sezzle payments", "create a Sezzle checkout session",
  "capture or refund a Sezzle order", "implement auth and capture with Sezzle",
  "add the Sezzle JS SDK", "add Sezzle express checkout". Not for
  Shopify/WooCommerce/BigCommerce/Magento/Wix — those have platform plugins.
metadata:
  author: Sezzle
  version: "1.1.0"
---

# Integrate Sezzle Checkout

Every path shares one spine: authenticate → create session → shopper approves → settle.

## Pick the path first

Use the interactive question tool:

| The merchant wants | Read |
|---|---|
| Server-side REST with an off-site redirect (the default) | Steps 1–6 below |
| In-context checkout (popup or iframe) via the JS SDK | Steps 1–6, plus § "JavaScript Checkout SDK" |
| Sezzle collects the shipping address, you quote shipping/tax mid-checkout | Steps 1–6, plus § "JavaScript Checkout SDK" and § "Express checkout" |
| Refund, release, reauthorize, partial capture, upcharge | Step 5 |
| Webhooks (subscribing, signature verification, event payloads) | the `sezzle-webhooks` skill |

## Before starting: load API context

With the `sezzle-docs` MCP server, read via `query_docs_filesystem_sezzle`:

1. `cat /docs/api/core/sessions/postv2session.mdx` for the full Create Session schema. Always.
2. `cat /docs/api/core/orders/getv2order.mdx` for the Get Order response, including the `authorization` child attributes. Always.
3. `cat /docs/guides/auth-and-capture.mdx` for AUTH vs CAPTURE semantics, when choosing intent in Step 2.
4. `cat /docs/guides/direct/integration.mdx` for the JS SDK and express paths.
5. `cat /docs/guides/express/quick-guide.mdx` and `/docs/guides/express/express-checkout.mdx` for the express path only.

If it isn't installed, ask the user to run `npx add-mcp https://docs.sezzle.com/mcp --name
sezzle-docs`. Before writing code that parses any Sezzle response, read that endpoint's own
response schema. Shapes differ between endpoints, and a request body doesn't predict a response.

## Environments

| | Sandbox | Production |
|---|---|---|
| API base | `https://sandbox.gateway.sezzle.com/v2` | `https://gateway.sezzle.com/v2` |
| Dashboard | `https://sandbox.dashboard.sezzle.com/merchant` | `https://dashboard.sezzle.com/merchant` |

Keys are environment-specific and not transferable. Production additionally requires a linked
card and bank account on the merchant account; sandbox does not. Testing refunds in sandbox needs
a payment method on the account, or the `Delayed Settlement` payout option.

**If the user has no Sezzle account yet, stop and use the `sezzle-prototype-sandbox` skill.** It
covers sign-up, the manual application approval, key generation and shopper test data. Do not
guess at a signup or key-generation flow.

## Step 1: Authenticate

Check the codebase and `.env` for existing keys. Grep `SEZZLE`, since names are often
environment-suffixed (`SEZZLE_SANDBOX_PUBLIC_KEY`), and select the API base URL from the same
switch. **Human handoff if absent:** only an Admin dashboard user can generate keys, so stop and
ask. Save to `.env` and confirm `.gitignore` covers it.

```
POST /v2/authentication
{ "public_key": "...", "private_key": "..." }
```

Response: `{ "token": "...", "expiration_date": "...", "merchant_uuid": "..." }`

Use `Authorization: Bearer {token}` on all subsequent calls. **Tokens expire after 120 minutes.**
Refresh proactively; you may hold several. A 401 on any call means re-authenticate and retry once.

## Step 2: Decide intent (AUTH vs CAPTURE)

- `CAPTURE` (default): captured automatically after a successful checkout. Right for most merchants.
- `AUTH`: authorization only. Pick it when your backend gates fulfilment. You must capture before
  the authorization expires (default 30 min; Dashboard Settings allows 30 min to 7 days). **On
  expiry capture is denied and the uncaptured amount is released.** The order stays readable, with
  the released amount under `authorization.releases` and `authorization.expiration` in the past,
  so don't treat a missing order as the expiry signal. Recovery is `reauthorize`, not a new session.

Capture-first (Step 4) is correct under either intent.

## Step 3: Create a session

```
POST /v2/session
{
  "cancel_url":   { "href": "https://merchant.example/cancel" },
  "complete_url": { "href": "https://merchant.example/complete" },
  "customer": {
    "first_name": "Jane", "last_name": "Doe",
    "email": "jane@example.com", "phone": "5555045294",
    "shipping_address": { "name": "Jane Doe", "street": "123 W Lake St", "city": "Minneapolis", "state": "MN", "postal_code": "55408", "country_code": "US" }
  },
  "order": {
    "intent": "CAPTURE",
    "reference_id": "ord-12345",
    "description": "Order ord-12345",
    "order_amount": { "amount_in_cents": 10000, "currency": "USD" },
    "items": [ { "name": "Widget", "sku": "sku-1", "quantity": 1, "price": { "amount_in_cents": 10000, "currency": "USD" } } ],
    "checkout_mode": "redirect"
  }
}
```

Response (201) includes `order.uuid` and `order.checkout_url`. Redirect the shopper to
`checkout_url`.

- Required in `order`: `intent`, `reference_id`, `description`, `order_amount`. `description` is
  easy to miss and a 400 if omitted. `items` and `customer` are optional but boost conversion.
- All amounts are **in cents** (10000 = $100.00). Range 100 to 5,000,000; merchant config may set
  a higher minimum.
- `reference_id`: alphanumeric, dashes, underscores only.
- `cancel_url`/`complete_url` are objects (`{ "href": ... }`, not strings), GET only. `cancel_url`
  is required for `redirect` mode. `complete_url` is required for `redirect` mode **or** any
  session with `customer.tokenize: true`, including `iframe`/`popup`, where it's easy to assume
  neither is needed.
- `checkout_mode` lives on `order`: `redirect` (default), `iframe`, `popup`. An unrecognized value
  is silently treated as `redirect`, not rejected. For `iframe`/`popup` see
  § "JavaScript Checkout SDK".
- Long Term Lending merchants MUST send a complete `shipping_address` (no PO boxes). The address
  phone key is `phone`.
- Sports & Hobbies / Hunting & Fishing merchants must send `category_path` per item (delimiter
  `>`). The API won't tell you the account is in these categories, so ask the user.
- Express checkout adds a top-level `express_checkout_type` and `order.requires_shipping_info`;
  see § "Express checkout".

**Validation failures return 400** with a body of `[{ "code", "message", "location" }]`, where
`location` names the offending field (e.g. `complete_url.href`). Read `location` and fix that
field rather than reshaping the request.

## Step 4: Handle completion

Persist `order.uuid` from the 201. The redirect back to `complete_url` carries **no** order or
session identifier (only `customer-uuid`, and only when tokenization succeeded), so the stored
UUID is your only correlation handle.

The completion signal (the return to `complete_url`, or the SDK's `onComplete`) means the shopper
finished and the order is authorized. **On that signal attempt capture directly**; polling
`GET /v2/order` first adds a round trip and puts `authorization` parsing on the checkout-blocking
path.

- `POST /v2/order/{order_uuid}/capture` with the full amount. **200** means captured.
- **422 `already_completed`** means it was already captured, either by a prior retry or by
  Sezzle's auto-capture under `CAPTURE` intent. Treat it as success.
- Match on the `code`, not the 422 status: a capture before the shopper authorized is also a 422
  (`invalid` / "Order not authorized"), and that one is a real failure.

For **asynchronous confirmation**, subscribe to the webhook matching your intent (sezzle-webhooks
skill). **The event depends on `intent`:** `CAPTURE` fires `order.captured` and never
`order.authorized`; `AUTH` fires `order.authorized`. Subscribing to the wrong one is a silent
hang, not an error.

For **status reporting** outside the confirm flow, use `GET /v2/order/{order_uuid}`. The
`authorization` object is absent until checkout completes, so handle "absent" separately from
`approved: false`. To total `authorization.captures` / `.refunds` / `.releases`, sum
`entry.amount.amount_in_cents`.

## Step 5: Manage the order

Send `Sezzle-Request-Id` on every mutating call for idempotency, capped at **36 characters**.
`POST /v2/session` does **not** accept it. Don't use the order UUID as the key, because upcharge
rejects that collision.

| Operation | Endpoint | Notes |
|---|---|---|
| Capture | `POST /v2/order/{order_uuid}/capture` | Body `{ "capture_amount": { "amount_in_cents", "currency" } }`. Full or partial (split shipments). Before auth expiry only. A partial capture leaves the auth open and does **not** release the remainder. |
| Refund | `POST /v2/order/{order_uuid}/refund` | Body is a bare `{ "amount_in_cents", "currency" }`, *not* wrapped like capture. Bounded by captured-minus-refunded, so uncaptured orders fail with 422. |
| Release | `POST /v2/order/{order_uuid}/release` | Body is the same bare price object. Releases uncaptured authorization, full or partial. The normal cancel flow. |
| Reauthorize | `POST /v2/order/{order_uuid}/reauthorize` | Body is the same bare price object. See below. |
| Update order | `PATCH /v2/order/{order_uuid}` | Body `{ "reference_id": "..." }`, the only updatable field. Returns 204. |
| Delete checkout | `DELETE /v2/order/{order_uuid}/checkout` | Abandon an un-completed checkout. Fails once authorized. |

**Reauthorize:**

- Allowed once the auth has **expired** or is otherwise **finished**, including a release, so a
  release you didn't mean can be undone immediately. Rejected while the auth is still live.
- Within 60 days of the original auth, for at most the unused amount.
- Creates a **new order** with the same `reference_id`, so store the new UUID.
- **Returns 200 even when the shopper is declined.** Check `authorization.approved`.

**Two endpoints to avoid in a direct integration:**

- **`PATCH /v2/order/{order_uuid}/checkout`** is express-checkout shipping selection, not a
  generic order update. To change `reference_id` use `PATCH /v2/order/{order_uuid}`.
- **`POST /v2/order/{order_uuid}/upcharge`** is documented as *"in development and not yet
  available for production use."* It also doesn't do what the name suggests: it creates a **new,
  separate** Pay-In-Full order for the extra amount and leaves the original untouched, capped at a
  cumulative 15% of the original, same currency. Body `{ "intent", "upcharge_amount": { ... } }`.

## Step 6: Test in sandbox

Completing a checkout needs a browser, a shopper login and an OTP, so you cannot verify end to
end alone. Don't report success after a 201. Say which half you verified.

**Verify unaided (do all of it):**
- `POST /v2/authentication` returns a token and a future `expiration_date`.
- `POST /v2/session` returns 201 with `order.uuid` and `order.checkout_url`, and `order.intent`
  matching what you sent.
- `GET /v2/order/{order_uuid}` returns 200 with `checkout_status: active` and **no**
  `authorization`. That's the correct pre-checkout state, and it proves you stored the right UUID.
- Capture that UUID before checkout: a 422 `invalid` proves you distinguish it from
  `already_completed`.
- Your `complete_url` handler resolves the stored `order.uuid` without reading the query string.
- SDK paths only: after loading the payment surface and changing the cart, exactly one Sezzle
  button exists and one session per total, counted in a production build. A framework's dev-mode
  double invoke (React StrictMode) doubles that; any more means a lifetime is tied to a
  re-render.
- Omit `description` from a session and confirm you surface the 400 `location` rather than
  swallowing it.

**Human handoff for the rest.** Ask the user to complete a sandbox checkout: cart total
$20–$2500, US/CA IP (VPN otherwise), shopper OTP `123123`, Visa test card `4242 4242 4242 4242`.
If Sezzle doesn't appear at checkout, check that total range and the IP first; the range is a
heuristic, not an API limit. Then confirm the shopper landed on `complete_url`, and verify from
your side that capture succeeds (or returns `already_completed`), followed by a refund.
`sezzle-prototype-sandbox` covers the setup in full.

## After sandbox works

Tell the user what remains before real money: production account and keys, re-subscribed
webhooks, re-requested allowlists, and a safe first transaction. Use `sezzle-go-live` when ready.

## JavaScript Checkout SDK

For `popup` / `iframe` / SDK-`redirect` integrations. It replaces only "redirect the shopper to
`checkout_url`"; Steps 1–6 still apply.

### Minimum working sequence

The constructor alone renders nothing and starts nothing: `renderSezzleButton()` → `init()` → `startCheckout()`.

```html
<script type="text/javascript" src="https://checkout-sdk.sezzle.com/checkout.min.js"></script>
<div id="sezzle-smart-button-container"></div>
```
```javascript
const checkoutSdk = new Checkout({
  mode: "popup",          // popup (default, recommended) | iframe | redirect
  publicKey: "sz_pub_...",
  apiMode: "sandbox",     // sandbox | live  (default: live)
  apiVersion: "v2"        // pass explicitly, see below
});

checkoutSdk.renderSezzleButton("sezzle-smart-button-container");

checkoutSdk.init({
  onClick: function (event) {
    event.preventDefault();
    checkoutSdk.startCheckout({ checkout_payload: { order: { /* Create Session body */ } } });
  },
  onComplete: function (response) { /* response.data.order_uuid, .session_uuid */ },
  onCancel:   function (response) {},
  onFailure:  function (response) {}
});
```

### Constructor options

| Option | Default | Notes |
|---|---|---|
| `mode` | `popup` | `popup` works out of the box. Use `iframe` only when popups are blocked (webview or embedded browser); it needs allowlisting, see below. `redirect` sends the shopper off-site anyway, so the SDK buys you nothing over the REST flow. |
| `publicKey` | — | Required. |
| `apiMode` | `live` | Set `sandbox` explicitly while testing. The default is production. |
| `apiVersion` | `v2` | Treat as required: omit it and `onComplete` arrives without `order_uuid`/`session_uuid`, leaving nothing to capture against. |
| `button_id` | — | Binds to your own existing button instead of `renderSezzleButton()`. |

### Starting checkout: two forms

**A) `checkout_payload`.** The SDK POSTs `/v2/session` from the browser with the public key. The
payload schema is the Create Session body (Step 3).

**B) `checkout_url`.** A session your server already created. The SDK `mode` must match the
`checkout_mode` used at session creation, and `onComplete` then gives you only `checkout_uuid`.

In `popup`/`iframe`, cancel and complete URLs are optional with form A. With form B, or with
`mode: "redirect"`, supply both and include the parent window's `origin`. Otherwise session
creation fails with a 400 naming `cancel_url.href`.

### Opening the popup: when to create the session

**The popup must open inside the shopper's click.** With form B the `checkout_url` must already
exist, so `await`ing your session-create inside `onClick` opens it too late and Safari and Firefox
block it. **Default: create the session when the shopper selects Sezzle**, so the branded button's
own click opens the popup with nothing awaited. It replaces the generic submit; Sezzle documents
both that and the payment-methods radio pattern (`/docs/guides/direct/integration`).

Ask the merchant before deviating; the alternatives trade clicks against orphan orders:

| | Clicks | Sessions created |
|---|---|---|
| On selection (default) | 1 | Only shoppers who pick Sezzle |
| On mount | 1 | Every view of the payment step, skewing conversion metrics |
| Generic submit, then the Sezzle button | 2 | Only shoppers who pick Sezzle |

A session's amount is frozen at creation and `checkout_expiration` is ~30 minutes out, so on a
single-page checkout where the total can still change, take the two-step or recreate the session
on every change.

**Cache the session across anything that re-runs your component**: a selection toggle, a cart
change, a parent state update, a dev-mode double invoke. Each of those creates another orphan
order if session creation runs alongside it. Keep the session in durable state (a `ref`, closure,
or module variable) and discard it only on cancel, failure, or a changed total.

### Callbacks

All callbacks, `onClick` included, go to `init()`, never the constructor. `init()` does nothing at
all when `onClick` is missing or the button element isn't found, and raises no error.

| Callback | Payload |
|---|---|
| `onComplete` | `response.data.order_uuid`, `.session_uuid` (form A) or `.checkout_uuid` (form B); `response.data.origin`. |
| `onCancel` | `response.data.status === "cancel"` plus `checkout_uuid` / `session_uuid` / `order_uuid`. |
| `onFailure` | Same identifiers plus `response.data.message` (e.g. `"Public Key is missing."`). Update the order and surface the failure. |

Customer tokenization UUIDs never arrive via `onComplete`. Subscribe to the `customer.tokenized`
webhook instead (sezzle-webhooks skill).

### Settling: what the browser can and cannot do

The SDK can capture client-side with the public key:

```javascript
checkoutSdk.capturePayment(response.data.order_uuid, {
  capture_amount: { amount_in_cents: 10000, currency: "USD" },
  partial_capture: false
});
```

Skip the client-side capture under `CAPTURE` intent; Sezzle captures automatically, and Step 4's
backend capture then returns `already_completed`.

**But capture is the only money operation available in the browser.** Refund, release and
reauthorize need a bearer token, which needs the private key, which must stay server-side. So a
session created with form A leaves `order_uuid` in the browser only:

> **POST `order_uuid` to your own backend inside `onComplete`, before or alongside the capture.**
> Otherwise the order becomes unrefundable from your server, because you have no handle for it.

Prefer form B (server-created session) when your backend owns the order lifecycle, which is the
usual case for a real storefront.

### CSP and iframe allowlisting

1. CSP must allow `*.sezzle.com` in `script-src` (every mode) **and** `frame-src` (iframe).
2. iframe only: Sezzle must allowlist your domains, per environment. Until that lands the iframe
   won't load, and there's no API error to diagnose it with.

The second one needs a **human handoff:** ask the user to send Sezzle their Merchant UUID and the
exact domains for each environment (e.g. "enable uat1.mysite.com, uat2.mysite.com in sandbox and
www.mysite.com, mysite.com in production"). Use `popup` until the allowlist is in place.

### In a component framework

Three lifetimes, and none of them matches your component's: the **script** loads once per page,
the **SDK instance** is built once per mount, the **session** is created once per total. Tie one
of them to a re-render and you get orphan orders, a duplicated button, or callbacks firing on an
instance you already discarded. Inside Sezzle's checkout that appears as a generic error, with
nothing in your own logs. Copy this shape:

```tsx
// Once per page, not per mount: two script tags mean two globals racing.
let sdkPromise: Promise<SezzleCheckoutConstructor> | null = null;

const loadSdk = () => {
  if (window.Checkout) return Promise.resolve(window.Checkout);
  sdkPromise ??= new Promise((resolve, reject) => {
    const script = document.createElement("script");
    script.src = "https://checkout-sdk.sezzle.com/checkout.min.js";
    script.onload = () =>
      window.Checkout ? resolve(window.Checkout) : reject(new Error("no Checkout global"));
    script.onerror = () => { sdkPromise = null; reject(new Error("Sezzle SDK failed to load")); };
    document.head.appendChild(script);
  });
  return sdkPromise;
};

function SezzleButton({ totalInCents, cartSignature }) {
  // A ref, not state: the click handler reads it, a re-render must not discard it.
  const sessionRef = useRef(null);

  // One session per total. Depending on the cart *array* instead of a derived signature
  // re-runs on every parent render and mints an orphan order each time.
  useEffect(() => {
    let live = true;
    prepareCheckoutOnYourBackend({ totalInCents }).then((s) => { if (live) sessionRef.current = s; });
    return () => { live = false; };
  }, [cartSignature, totalInCents]);

  // Empty dep array: rebuilding the instance re-renders the button and leaves Sezzle's
  // callbacks bound to the one you discarded.
  useEffect(() => {
    let live = true;
    (async () => {
      const Checkout = await loadSdk();
      if (!live) return;
      const sdk = new Checkout({ mode: "popup", publicKey, apiMode: "sandbox", apiVersion: "v2" });
      await sdk.renderSezzleButton("sezzle-smart-button-container");
      sdk.init({
        onClick: (event) => {
          event.preventDefault();
          const session = sessionRef.current;
          if (!session) return;   // don't await here: the popup must open inside the click
          sdk.startCheckout({ checkout_payload: session.payload });
        },
        onComplete: (r) => settleOnYourBackend(r.data.order_uuid),
        // Discard, or the next attempt reuses a session Sezzle has already closed.
        onCancel: () => { sessionRef.current = null; },
        onFailure: () => { sessionRef.current = null; },
      });
    })();
    return () => { live = false; };
  }, []);

  return <div id="sezzle-smart-button-container" />;
}
```

The lifetimes map onto any framework's equivalents (Vue `ref`/`onMounted`, Svelte module
scope/`onMount`). With form B, put `checkout_url` and `order_uuid` in the same ref and pass those
to `startCheckout` instead.

## Express checkout

Sezzle collects the shipping address and your backend quotes shipping and tax mid-checkout. It's
the JavaScript Checkout SDK above with a different bundle and one extra callback; § "In a
component framework" applies with two changes: load `express_checkout.min.js`, and add
`onCalculateAddressRelatedCosts` to the `init()` callbacks. A rebuilt SDK also loses that callback.

- **Different bundle:** `https://checkout-sdk.sezzle.com/express_checkout.min.js`. The global is
  still `Checkout` and the constructor options are identical.
- **`express_checkout_type` is a top-level key** of the checkout payload, a sibling of `order`
  rather than a field inside it.
- **`order.requires_shipping_info: true`** for `single-step` and `multi-step`.
- **One extra callback:** `onCalculateAddressRelatedCosts`, required for `single-step` and
  `multi-step`.

| `express_checkout_type` | Use when |
|---|---|
| `single-step` | One shipping method, or you always use a default one. |
| `multi-step` | Shopper picks from your supported methods. |
| `no-shipping` | Nothing shippable, such as digital goods or BOPIS. No address callback needed. |

There is **no** `onCalculateTaxCost` callback. Tax is returned per shipping option as
`tax_amount_in_cents`.

```javascript
checkoutSdk.startCheckout({
  checkout_payload: {
    express_checkout_type: "multi-step",
    order: {
      intent: "AUTH",
      reference_id: "ord-12345",
      description: "Order ord-12345",
      requires_shipping_info: true,
      items: [ { name: "widget", sku: "sku-1", quantity: 1,
                 price: { amount_in_cents: 1000, currency: "USD" } } ],
      discounts: [ { name: "20% off", amount: { amount_in_cents: 1000, currency: "USD" } } ],
      order_amount: { amount_in_cents: 10000, currency: "USD" }
    }
  }
});
```

`order_amount` here is pre-shipping and pre-tax; the address callback supplies the final totals.
With form B, express passes the order UUID alongside the URL:
`startCheckout({ checkout_url, order_uuid })`.

### onCalculateAddressRelatedCosts

Signature: `async function (shippingAddress, order_uuid)`. Sezzle calls it once the shopper
supplies an address. Quote the options, write them to Sezzle, return `{ ok }`. This is the one
place `PATCH /v2/order/{order_uuid}/checkout` is correct (the exception to Step 5's warning).

```
PATCH /v2/order/{order_uuid}/checkout
Authorization: Bearer {token}
{
  "currency_code": "USD",
  "address_uuid": shippingAddress.uuid,
  "shipping_options": [
    { "name": "Standard Shipping", "description": "3-5 business days",
      "shipping_amount_in_cents": 1000, "tax_amount_in_cents": 500,
      "final_order_amount_in_cents": 11500 }
  ]
}
```

Return to Sezzle:

```javascript
return { ok: true };
// or
return { ok: false, error: { code: "merchant_unsupported_shipping_address" } };
```

`error.code` is `merchant_unsupported_shipping_address` (you don't ship there) or
`merchant_error` (something broke on your end).

**Keep the private key and bearer token out of the callback:** have it `fetch` your own endpoint,
which authenticates and PATCHes server-side. The docs' own example inlines both; don't copy it.

**Constraints that break checkout silently:**

- **40-second budget.** Miss it and checkout fails with `merchant_shipping_cost_timeout`, and the
  shopper cannot complete the purchase.
- **Options are immutable** once written, unless the shopper changes their address.
- **Count must match the type.** `multi-step` with one option degrades to the `single-step`
  experience; `single-step` with several shows the shopper an error; either type with zero sends
  them back to re-check their address.
- **Only while the checkout is open.** Once it has expired, been authorized, or is being
  completed, the PATCH returns `409` (or `422` `checkout already completed`). Don't retry: return
  `{ ok: false, error: { code: "merchant_error" } }` and start a new session if the shopper
  tries again.

### Settling express orders

Settle as in § "Settling: what the browser can and cannot do". The shipping address and the
method the shopper chose come back from `GET /v2/order/{order_uuid}` as
`customer.shipping_address` and `shipping_method`. The address callback is also a good moment to
save the address to your own order record.

## Source docs

Beyond the pages listed under "Before starting":

- `/docs/api/test-cards`
- `/docs/plugins/` for platform plugins (Shopify, WooCommerce, BigCommerce, Magento 2, Wix, …)
