Skip to main content

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

For pages without a bundler, load the global build:

Configuration

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

Basic redirect flow

After successful payment, COPE redirects to success_url with your checkout metadata, the affiliate reference when there is one, and the order ID appended:

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:

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: 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. 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:
For a handful of fields, write one metadata[...] parameter per key:
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:
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 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:
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: 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.
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: 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. 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():
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

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

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 — 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. 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: A buyer who opened ?metadata[ref]=A-4471&metadata[campaign]=autumn&aff=partner42 arrives at:
%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().
Read the full embedded checkout guide before launching iframe checkout. It covers registered embed origins, iframe security, postMessage events, and fallback behavior.

Core methods

Errors

The SDK exposes typed errors:
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.