> ## Documentation Index
> Fetch the complete documentation index at: https://docs.staging.cope-demo.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Checkout SDK overview

> Use the COPE Checkout SDK to build custom product and cart pages that hand buyers off to hosted checkout by redirect or iframe.

# Checkout SDK

The COPE Checkout SDK is a browser SDK for buyer checkout flows. It lets you fetch product data, build a cart, calculate final prices, create a hosted checkout session, and either redirect the buyer to COPE checkout or mount that checkout inside your page.

Use the SDK when your site owns the product page or shopping experience and COPE owns payment collection, tax calculation, order creation, and payment lifecycle events.

## Install

<CodeGroup>
  ```bash npm theme={"theme":{"light":"github-light","dark":"github-dark"}}
  npm install @copecart/sdk
  ```

  ```bash pnpm theme={"theme":{"light":"github-light","dark":"github-dark"}}
  pnpm add @copecart/sdk
  ```
</CodeGroup>

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
import { CopeCart } from "@copecart/sdk"

const cope = new CopeCart({
  publishableKey: "cope_pk_live_...",
})
```

For pages without a bundler, load the global build:

```html theme={"theme":{"light":"github-light","dark":"github-dark"}}
<script src="https://unpkg.com/@copecart/sdk@latest/dist/index.global.js"></script>
<script>
  const cope = new CopeCart.CopeCart({
    publishableKey: "cope_pk_live_...",
  })
</script>
```

## Configuration

| Option            | Required | Default              | Notes                                                                                   |
| ----------------- | -------- | -------------------- | --------------------------------------------------------------------------------------- |
| `publishableKey`  | Yes      | -                    | Business publishable key. It starts with `cope_pk_` and is safe to use in browser code. |
| `baseUrl`         | No       | COPE production API  | Override only when COPE support gives you an environment-specific API URL.              |
| `checkoutBaseUrl` | No       | The `baseUrl` origin | Used to validate checkout URLs returned by the API.                                     |

The SDK requires HTTPS except for `http://localhost` during development.

## Basic redirect flow

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
import { CopeCart } from "@copecart/sdk"

const cope = new CopeCart({
  publishableKey: "cope_pk_live_...",
})

const product = await cope.getProduct("prd_...")
const cart = await cope.createCart({ currency: product.currency })

await cope.addLine(cart.id, {
  product_id: product.id,
  plan_id: product.payment_plans[0].id,
  quantity: 1,
})

await cope.setBuyerIdentity(cart.id, {
  email: "buyer@example.com",
  tax_location: {
    country: "DE",
    postal_code: "10115",
  },
})

await cope.reprice(cart.id)

const checkout = await cope.checkout(cart.id, {
  success_url: "https://your-site.example/thank-you",
  cancel_url: "https://your-site.example/cart",
  consents: [{ type: "buyer_tos" }],
})

cope.redirectToCheckout(checkout)
```

After successful payment, COPE redirects to `success_url` with your [checkout metadata](#on-your-thank-you-page), the affiliate reference when there is one, and the order ID appended:

```txt theme={"theme":{"light":"github-light","dark":"github-dark"}}
https://your-site.example/thank-you?metadata%5Bref%5D=A-4471&affiliate=partner42&order=ord_...
```

## Redirect URLs

`success_url` and `cancel_url` must be registered for the business before you can pass them to `checkout()`. Register them in the COPE dashboard under **Settings → SDK**, on the API Settings page, in the **Redirect URLs** section — up to 10 of each.

Passing a URL that is not registered fails the checkout call with `422` and a field error per offending value. The `message` points at the same screen:

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "errors": [
    {
      "code": "invalid_redirect_url",
      "field": "success_url",
      "message": "success_url is not in the SDK integration allowlist. Register it at Settings → API → Redirect URLs."
    }
  ]
}
```

### Matching is exact

A registered entry is compared to the value you send as a **complete URL string**. It is not an origin match and not a path prefix, so every one of these is rejected when only `https://shop.example.com/thank-you` is registered:

| Value sent to `checkout()`                     | Result                    |
| ---------------------------------------------- | ------------------------- |
| `https://shop.example.com/thank-you`           | Matches                   |
| `https://shop.example.com`                     | Rejected — different URL  |
| `https://shop.example.com/thank-you/`          | Rejected — trailing slash |
| `https://shop.example.com/thank-you?status=ok` | Rejected — query string   |

If your landing page needs its own query parameters, register the full URL including them. You do not need to register a variant with COPE's parameters: they are appended after the value has been matched.

### What you can register

* HTTPS only. `http://` is rejected, including `http://localhost`, so redirect URLs cannot point at a local development server even though the SDK itself accepts `http://localhost` as a page origin.
* A public host. Loopback and private addresses such as `localhost`, `127.0.0.1`, `10.x.x.x`, `172.16-31.x.x`, `192.168.x.x`, and IPv6 `::1` or unique-local addresses are rejected.
* No credentials in the URL (`https://user:pass@…`) and no backslashes.

To exercise redirect completion from a development machine, use an HTTPS tunnel and register the tunnel URL.

### Omitting them

`success_url` and `cancel_url` are optional on `checkout()`. When you omit one, COPE uses the first URL registered for that field. When you send one, it must be registered.

Embedded checkout signals completion through postMessage events rather than a redirect, so iframe integrations can omit both. Keep at least one of each registered anyway: when `mountCheckout()` falls back with `fallback: "redirect"`, the buyer continues on COPE hosted checkout, and that page still needs a success and cancel destination to return them to afterwards.

## Your own reference on an order

COPE carries a small bag of *your* data through checkout and hands it back on the webhooks that follow, so you can match a COPE order to the record it belongs to in your own system — an invoice, a CRM lead, a seat booking, a campaign.

Attach it in code when you build the cart with the SDK, or put it on a checkout link and write no code at all.

### On a checkout link — no code needed

Both links the COPE dashboard hands out accept it — **Copy Product URL**, and **Copy 1-click Checkout URL** where 1-click checkout is enabled for your business — on the product's menu in the catalog. Take the link you copied and append a parameter.

For a single identifier — the common case, and one string is usually all you need — use `external_reference`:

```txt theme={"theme":{"light":"github-light","dark":"github-dark"}}
https://app.cope.com/checkout?product=<product-id>&plan=<plan-id>&external_reference=order-4711
https://app.cope.com/products/<product-id>?external_reference=order-4711
```

For a handful of fields, write one `metadata[...]` parameter per key:

```txt theme={"theme":{"light":"github-light","dark":"github-dark"}}
?metadata[order_id]=A-4471&metadata[campaign]=autumn&metadata[channel]=email
```

which arrives as `{"order_id":"A-4471","campaign":"autumn","channel":"email"}`. Reach for this spelling first. It stays readable in the link, so you can see at a glance what a buyer is being sent, and a mistake in one value costs you that value rather than everything.

Two things to know before you build on it:

* **Every value arrives as a string.** `?metadata[seat_count]=3` comes back as `"3"`, never `3`. A query string carries no types, so COPE does not invent one — and the guess would not stay local: it is snapshotted onto the order and repeated on every payment webhook for the life of that order, in a shape you never chose.
* **It is one level deep.** `metadata[campaign]` is read; `metadata[campaign][id]` is ignored outright rather than flattened into a key of its own, as are `metadata[]` and an unclosed `metadata[order_id`. An empty value (`?metadata[note]=`) is kept as an empty string — you typed the key, so the key travels.

**When you need real types or nesting, send the whole bag as one URL-encoded JSON object.** It is the only spelling that carries a number, a boolean, an array or a nested object:

```txt theme={"theme":{"light":"github-light","dark":"github-dark"}}
?metadata=%7B%22campaign%22%3A%7B%22id%22%3A7%7D%2C%22seats%22%3A3%7D
```

which is `{"campaign":{"id":7},"seats":3}` before encoding. Values may be strings, numbers, booleans, `null`, arrays, and further objects. A `metadata` value that is not a JSON **object** — a bare string, a number, an array, anything that fails to parse — is ignored rather than guessed at, because inventing a key for it would publish your value under a name you never chose on every later webhook. Parameters beside it are still read.

Whichever spelling you use, open one generated link yourself and confirm the value reaches the order before you send the batch out. A bag that breaks a [limit](#limits) is dropped and the buyer checks out anyway, so a bad link produces orders with no reference of yours on them and no error anywhere.

### How the three parameters combine

They compose, in a fixed order:

1. The JSON object is read first.
2. Each `metadata[key]` parameter merges over it, key by key.
3. A standalone `external_reference` parameter wins over both, wherever it sits in the link — provided it carries a value. An empty one is passed over, and a key of that name from another spelling survives instead.

So one link template can carry a fixed bag and vary the single identifier per buyer:

```txt theme={"theme":{"light":"github-light","dark":"github-dark"}}
?metadata=%7B%22campaign%22%3A%22autumn%22%7D&external_reference=order-4711
```

arrives as `{"campaign":"autumn","external_reference":"order-4711"}`.

Within any one spelling, **the last occurrence wins**. That is the opposite of what several URL libraries do, and the right way round for a link: appending `&external_reference=order-4711` to a template that already carries a placeholder is meant to replace it, not to lose to it. What the later one replaces follows the size of what the parameter names:

| Repeated parameter   | What the later one replaces                                                                                                                                                   |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `external_reference` | The identifier.                                                                                                                                                               |
| `metadata[campaign]` | That one key. Your other keys are untouched.                                                                                                                                  |
| `metadata`           | **The whole bag.** The first object's keys are gone, not merged — and if the later one fails to parse you are left with nothing, because the earlier one is not brought back. |

The query parameter names are lower-case and matched exactly — `Metadata[order_id]` is not read. Your own key names inside them are kept exactly as you write them, so `orderId` comes back as `orderId`.

### Encode the value, every time

A link is not a string you can paste a raw value into. Two characters end the parameter early, and neither produces an error you can see:

* `#` starts the URL fragment. **The browser never sends it to COPE**, so everything from `#` onwards is silently dropped — including any parameters written after it.
* `&` starts the next query parameter, so the rest of your value is read as a different parameter and discarded.

```txt theme={"theme":{"light":"github-light","dark":"github-dark"}}
Wrong:   ?external_reference=INV#2026&5     arrives as  INV
Right:   ?external_reference=INV%232026%265 arrives as  INV#2026&5
```

Percent-encode the whole parameter value — `encodeURIComponent()` in JavaScript, `urlencode()` in PHP, `urllib.parse.quote()` in Python — including the whole JSON string for `metadata`. Generate these links from code rather than editing them by hand.

What the mistake costs you depends on the spelling, and this is the strongest practical argument for the bracket form:

| In a link that was never encoded | `metadata[...]`                                                                                                                           | `metadata={...}`                                                                                                         |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| A bare `&` inside a value        | That value is cut short at the `&`. Every other key still arrives.                                                                        | The JSON is cut mid-string, stops parsing, and **the whole bag is lost** — including the keys written before the damage. |
| A bare `#` inside a value        | That value is cut short, and every parameter after it in the link is dropped by the browser. The keys written **before** it still arrive. | The whole bag is lost.                                                                                                   |

If you will not encode, put the value most likely to contain a stray character **last** in the link.

A link also has to survive being a URL. Browsers, mail clients and link shorteners each impose their own length limits, far below the 8 KB the field itself allows, so keep a link's bag to a short identifier and a handful of small values. Use the SDK when you need the whole budget.

### The link the buyer opens decides

These rules are about **product** links. A **1-click checkout link** (`https://app.cope.com/checkout?product=...`) is different by design: it starts a fresh cart on every visit, so the bag on the order is exactly the bag on that link.

A buyer who opens a product link (`https://app.cope.com/products/<product-id>`), leaves, and comes back — a reload, a second tab, a phone that dropped the page from memory — returns to the cart they already had, with their email, tax location, quantity and promo code intact. The link they are on *now* supplies that cart's metadata:

* **The current link wins.** Reopening the product with a different `external_reference` replaces the one the cart was carrying, so the order reconciles against the link the buyer actually used.
* **A link carrying no metadata clears nothing.** Silence is not an instruction to empty the bag. An order can therefore arrive carrying the `external_reference` of an earlier link rather than the one the buyer came back on. If a reference has to be on the order, put it on *every* link that can reach that product.
* **A link replaces the bag; it does not merge into it.** Keys the new link leaves out are gone, not inherited from the earlier one.
* **Two tabs are one cart.** Two product links for the same product, open in the same browser, normally share a single cart, and the order carries the bag that reached it **last** — usually the link opened most recently, not the one whose tab the buyer checked out in, though a tab still open on an older link can write its bag back over it later. Hand a buyer one link at a time.

### A link's value belongs to the buyer

Anything in a link is visible in the buyer's address bar and can be edited there before they reach checkout. So:

* **Never make an access decision from it.** Do not grant an entitlement, apply a price, or unlock an account because `external_reference` says so — a buyer who changes one character in the URL would be granting it to themselves. Authorize against your own record, looked up by `order.id` from the webhook.
* **Put nothing private in it.** No email addresses, no internal notes, no anything you would not show the buyer. Use an opaque identifier that means something in your system and nothing outside it.

Values your server sets through the API never pass through the buyer's browser and do not carry this caveat. A value the SDK sets does: the SDK runs on your page, in the buyer's browser, with a publishable key. Treat it like a link's — verify against your own record before you grant anything.

### With the Checkout SDK

Pass the same object to `createCart()`:

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
const cart = await cope.createCart({
  currency: "EUR",
  metadata: {
    external_reference: "order-4711",
    campaign: { id: 7 },
  },
})
```

The rest of the flow is unchanged — the value rides with the cart into the order. The SDK has no call that changes a cart's metadata afterwards, so assemble the bag before you create the cart.

### Limits

| Rule        | Limit                                                        |
| ----------- | ------------------------------------------------------------ |
| Total size  | 8 KB, measured as JSON                                       |
| Nesting     | 4 levels of objects or arrays, counting the outermost object |
| Entries     | 50 per object and 50 per array                               |
| Key length  | 64 characters; a blank key is rejected                       |
| Value types | string, number, boolean, `null`, array, object               |

The nesting limit counts containers, not values: `{"a":{"b":{"c":{"d":1}}}}` is four objects deep and is accepted, while wrapping `1` in a fifth object is not.

**COPE rejects the whole bag rather than trimming it.** Through the SDK the call fails with `422` and a `validation_error` on the `metadata` field, which your code can catch and correct.

**On a checkout link there is no call of yours to fail, and the buyer is not stopped.** COPE would rather lose your reference than the sale: the rejected bag is dropped and the buyer checks out normally, and on a cart they are resuming, whatever bag that cart already held stays. Either way the order arrives without the reference you meant to put on it, and nothing in the webhook says why. Check a generated link before you send the batch: open it in a browser with no cart of its own for that product, and read the `cart.cart.created` webhook it produces. A link whose bag was refused arrives there without your keys — which is the only warning you get.

### Reserved keys

Two key names are reserved so that COPE, the SDK and this link parameter all agree on where a value lives. Both are yours, and both are returned to you like any other key:

| Key                       | Meaning                                                                                                                                                              |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `external_reference`      | Your identifier for this purchase. The checkout link's `external_reference` parameter writes this key.                                                               |
| `intended_payment_method` | The payment method your integration recorded for this checkout. Carried for your own reconciliation; setting it on a link does not preselect anything for the buyer. |

COPE reserves four further names for its own internal flows — `authority_kind`, `clerk_user_id`, `event_sales_session_id` and `seller_device_id`. They are never returned in `order.metadata`, so do not use them for values of your own. Treat any key you did not set as absent.

### Where it comes back

| Webhook event                                                                                                                                                                                                                                              | Field                                                           |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `cart.cart.created`                                                                                                                                                                                                                                        | `metadata` at the top level — the bag the cart was created with |
| `cart.order.completed`                                                                                                                                                                                                                                     | `metadata` at the top level                                     |
| `payment.sale.succeeded`, `payment.refund.created`, `payment.refund.reversed`, `payment.chargeback.created`, `payment.chargeback.reversed`, `payment.dispute.opened`, `payment.dispute.won`, `payment.dispute.lost`, `payment.dispute.counter_fee.created` | `order.metadata`                                                |

`payment.failed` has a different producer and carries none of this — neither under `order` nor in its own `metadata` field. Apart from the webhooks, the bag comes back only [on your thank-you page](#on-your-thank-you-page) — in the redirect URL, which COPE's checkout responses also carry: no COPE API returns it on the cart or the order, so there is nothing to poll for.

Things worth knowing before you build on it:

* **Every payment on the order repeats it** — instalments and subscription renewals included — and so do the refunds, chargebacks and disputes that follow. That is the point: you reconcile a refund by the same reference you reconciled the sale by.
* **An empty object means you attached nothing.** The field is on every event emitted since it shipped, `{}` when you sent none; only deliveries from before then omit it. Parse defensively and treat absent and empty as the same thing.
* **`external_reference` is not unique per order.** A post-purchase upsell is a separate order that repeats the originating order's reference. Key your records on `order.id` and treat your own reference as a grouping handle.
* **The cart carries it, not the visit.** A product link hands its bag to the cart the buyer already had rather than starting a new one, so the last link opened is the one that decides — see [the link the buyer opens decides](#the-link-the-buyer-opens-decides). A later link changing a cart's bag raises no event of its own, so the order events are where you read the final value.
* **It is stored with the order and delivered verbatim** to every webhook endpoint you register. Keep personal and sensitive data out of it — and out of links especially, where the buyer can read and change it.

### On your thank-you page

When the buyer is sent to your page after paying — the checkout's `success_url`, or the product's custom thank-you page — COPE appends the same parameters whichever way the buyer paid:

| Parameter            | Value                                                                                                                                    |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `metadata[<key>]`    | One per top-level key of the bag, in the same spelling a checkout link takes.                                                            |
| `affiliate`          | The affiliate reference from the buyer's link (`?aff=`), when there is one — the value the webhooks carry as `attribution.affiliate_id`. |
| `order`              | The order ID (`ord_…`), the same ID the webhooks and the API use.                                                                        |
| `metadata_truncated` | `true`, only when a key had to be left out (see below).                                                                                  |

A buyer who opened `?metadata[ref]=A-4471&metadata[campaign]=autumn&aff=partner42` arrives at:

```txt theme={"theme":{"light":"github-light","dark":"github-dark"}}
https://your-site.example/thank-you?metadata%5Bcampaign%5D=autumn&metadata%5Bref%5D=A-4471&affiliate=partner42&order=ord_...
```

`%5B` and `%5D` are `[` and `]`, encoded; your framework decodes them like any other parameter, so `metadata[ref]` reads `A-4471` (PHP `$_GET['metadata']['ref']`, Rails `params[:metadata][:ref]`, JavaScript `new URLSearchParams(location.search).get("metadata[ref]")`).

* **Keys arrive sorted by name, character by character** (`Z` before `a`, `a_b` after `aB`), not in the order you wrote them.
* **Every value is text.** Numbers and booleans are written as `3` and `true`, `null` as an empty value, and an object or array as its JSON — which a checkout link reads back through the `?metadata={json}` spelling.
* **The URL is kept to 2,000 characters**, the length every proxy and server in front of a page accepts. A key whose value would take it past that is left out — the smaller ones are still sent — and `metadata_truncated=true` is added so your page knows. The whole bag is always on the order's webhooks. `affiliate` and `order` are never left out.
* **COPE's parameters come last.** If your own URL already uses `order` or `affiliate`, most frameworks keep the last value, which is COPE's.
* **A key containing `[` or `]` is left out** (and counted as truncated), because `metadata[a][b]` would be read as a nested parameter and clash with `metadata[a]`.
* **A URL is not private.** It lands in the buyer's history, your server and CDN logs, and your analytics. That is why COPE never adds the buyer's email, and one more reason to keep personal data out of the bag. Treat what arrives as buyer-controlled input: anyone can open your page with any parameters, so confirm the order by its `order` ID before acting on it.

## Embedded checkout

To keep the buyer on your page, create checkout with `embed_origin` and mount it with `mountCheckout()`.

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
const checkout = await cope.checkout(cart.id, {
  embed_origin: window.location.origin,
  consents: [{ type: "buyer_tos" }],
})

const mounted = cope.mountCheckout("#cope-checkout", checkout, {
  fallback: "redirect",
  onReady: () => {
    document.querySelector("#cope-checkout")?.removeAttribute("hidden")
  },
  onSuccess: () => {
    window.location.href = "/thank-you"
  },
  onError: ({ code }) => {
    console.error("COPE checkout iframe error", code)
  },
})
```

Read the full [embedded checkout guide](./embedded-checkout) before launching iframe checkout. It covers registered embed origins, iframe security, postMessage events, and fallback behavior.

## Core methods

| Method                                     | Purpose                                                                        |
| ------------------------------------------ | ------------------------------------------------------------------------------ |
| `getProduct(productId)`                    | Fetch public product details and payment plans.                                |
| `createCart(payload)`                      | Create a cart and store the checkout credential needed for later cart updates. |
| `addLine(cartId, payload)`                 | Add a product, payment plan, and quantity.                                     |
| `setBuyerIdentity(cartId, payload)`        | Set buyer location and contact data for tax and checkout.                      |
| `reprice(cartId)`                          | Calculate taxes, shipping, discounts, and final totals.                        |
| `checkout(cartId, payload)`                | Create a hosted checkout session.                                              |
| `redirectToCheckout(checkout)`             | Navigate the browser to hosted checkout.                                       |
| `mountCheckout(target, checkout, options)` | Mount hosted checkout inside an iframe.                                        |
| `cancelCheckout(checkoutId)`               | Cancel an open checkout session.                                               |
| `destroy()`                                | Abort in-flight requests, remove mounted iframes, and clear SDK cart state.    |

## Errors

The SDK exposes typed errors:

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
import {
  CopeApiError,
  CopeCartExpiredError,
  CopeNetworkError,
} from "@copecart/sdk"

try {
  await cope.reprice(cart.id)
} catch (error) {
  if (error instanceof CopeApiError) {
    console.log(error.status, error.code, error.errors)
  }

  if (error instanceof CopeCartExpiredError) {
    // A replacement cart starts with an empty bag — re-attach your metadata.
    const replacement = await cope.createCart({
      currency: "EUR",
      metadata: { external_reference: "order-4711" },
    })
  }

  if (error instanceof CopeNetworkError) {
    console.log("Retry later")
  }
}
```

Treat 4xx API errors as permanent for the same payload. Fix the input and retry with a new request. The SDK retries selected transient network or server failures with backoff.
