> ## 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.

# Metadata and attribution

> Pass your own reference and marketing attribution (UTM parameters, ad click ids, affiliate links) into a COPE checkout, and read them back on webhooks and your thank-you page.

# Metadata and attribution

COPE carries two kinds of data through a checkout for you and hands both back once the buyer has paid:

* **Your own data.** `metadata` is a small bag of keys and values, and `external_reference` is one identifier in it. Use them to match a COPE order to the record it belongs to in your system: an invoice, a CRM lead, a seat booking, a campaign.
* **Marketing attribution.** The UTM parameters and ad click ids of the link the buyer arrived on, and the affiliate code of the link (`?aff=`). Use them to see which campaign or partner brought the buyer.

This page covers what each way into checkout accepts, how to send each value, what happens when a buyer comes back on another link, and where every value comes back.

## What COPE carries for you

| Way into checkout | `metadata` and `external_reference` | UTM parameters and ad click ids | Affiliate link click (`?aff=`) |
| - | - | - | - |
| Product link, `https://app.cope.com/products/<product-id>` | Link parameters | Read from the link | Read from the link |
| [1-click checkout link](https://docs.staging.cope-demo.com/checkout-sdk/overview.md#1-click-checkout-links), `https://app.cope.com/checkout?product=<product-id>` | Link parameters | Read from the link | Read from the link |
| [Offer](https://docs.staging.cope-demo.com/checkout-sdk/overview.md#prices-payment-plans-and-offers), `POST /v1/commerce/offers` | `metadata` and `external_reference` members; the buyer cannot change them | None | None |
| `createCart()` in the Checkout SDK | `metadata`, with `external_reference` as a key inside it | `attribution` | Not set by the call |
| [`createPrefilledCheckout()`](./prefilled-checkout) | `metadata`, and `externalReference`, which is required | `attribution` | Not set by the call |
| [Checkout from your server](https://docs.staging.cope-demo.com/checkout-sdk/server-checkout.md), `POST /v1/commerce/checkouts` | `metadata`, and `external_reference`, which is required; the buyer cannot change them | None: an `attribution` member is refused with `400 invalid_request` | None |
| [Embedded checkout](https://docs.staging.cope-demo.com/checkout-sdk/embedded-checkout.md) | What the cart already carries | Nothing from your page's URL | Nothing from your page's URL |

Embedded checkout and the hosted checkout page (a checkout's `checkout_url`) read no parameters of their own: they carry what the cart behind the checkout already holds, whichever way it was created. An offer's link opens its checkout without the query string it was opened with, so parameters appended to it are not read.

## Your own data: metadata and external\_reference

### On a checkout link

Both links the COPE dashboard hands out take 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, 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.

* **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: the value is stored on the order and repeated on every payment webhook for the life of that order, and a guessed type would reach you in a shape you never chose.
* **It is one level deep.** `metadata[campaign]` is read; `metadata[campaign][id]` is ignored 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 link 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. Parameters beside it are still read.

The 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`.

### How the three spellings 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 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**, so appending `&external_reference=order-4711` to a template that already carries a placeholder replaces 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. |

### 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, which 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.

Keep a link's bag to a short identifier and a few small values: the whole link has to pass mail clients, link shorteners and the web servers that open it, and a link too long for them may not open at all. Use the SDK or your server when you need the whole budget.

### With the Checkout SDK

Pass the bag to `createCart()`, with your identifier as the `external_reference` key:

```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 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. [`createPrefilledCheckout()`](./prefilled-checkout) takes the same `metadata`, and your identifier as its own `externalReference` field.

### From your server

An [offer](https://docs.staging.cope-demo.com/checkout-sdk/overview.md#prices-payment-plans-and-offers) and a [checkout from your server](https://docs.staging.cope-demo.com/checkout-sdk/server-checkout.md) take `external_reference` and `metadata` as two separate members. Send your identifier as `external_reference`, never inside `metadata`, where it is refused with `422 reserved_metadata_key`. A checkout from your server requires it and refuses a request without it with `422 validation_error`; on an offer it is optional. Where you send it, it is 1 to 255 characters and not only spaces; otherwise the request is refused with `422 validation_failed`. The buyer cannot change either on the checkout page (see [offers and checkouts from your server](#offers-and-checkouts-from-your-server)).

### Limits

| Rule | Limit |
| - | - |
| Total size | 8 KB, measured as JSON, `external_reference` included |
| 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. A field you send beside the bag, `external_reference` on an offer, a prefilled checkout or a checkout from your server, and `intendedPaymentMethod` on a prefilled checkout, is added after the 50-entry check, so the bag can hold 50 entries of your own beside it.

**COPE rejects the whole bag rather than trimming it.** Where a call of yours sends it, the call fails and nothing is created:

| Sent by | Refused with |
| - | - |
| `createCart()` | `422 validation_error`, on the `metadata` field |
| `createPrefilledCheckout()` | `422 validation_error`, on the `metadata` field |
| An offer or a checkout from your server | `422 validation_failed` |

**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. [Test a link](#test-a-link-before-you-send-it) before you send it out.

### Reserved keys

Two key names are reserved so that COPE, the SDK and the link parameter 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 link's `external_reference` parameter, `createPrefilledCheckout()`'s `externalReference` and the `external_reference` member of an offer or a checkout from your server all write this key. |
| `intended_payment_method` | The payment method your integration recorded for this checkout. Carried for your own reconciliation; setting it 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 any webhook's metadata, so do not use them for values of your own. Treat any key you did not set as absent. An offer and a checkout from your server refuse all six inside `metadata` with `422 reserved_metadata_key`.

## UTM parameters and ad click ids

### On a link

A product link and a 1-click checkout link read these parameters from the URL the buyer opens, and COPE stores them with the cart:

| Kind | Parameters |
| - | - |
| UTM parameters | `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term` |
| Ad click ids | `fbclid`, `gclid`, `ttclid`, `msclkid` |

```txt theme={"theme":{"light":"github-light","dark":"github-dark"}}
https://app.cope.com/products/<product-id>?utm_source=newsletter&utm_medium=email&utm_campaign=autumn
```

* **Only these nine are carried.** Parameter names are lower-case and matched exactly, so `UTM_Source` is not read. Other parameters, `utm_id`, `gbraid` and `wbraid` among them, are not captured.
* **Send each parameter once, and only with a value.**
* **Only these two kinds of link read them.** An offer's link, a checkout's `checkout_url` and embedded checkout do not.

### Attribution with the Checkout SDK

When your own page builds the cart, read the parameters from your page's URL and pass them as `attribution` to `createCart()` or `createPrefilledCheckout()`. COPE stores the object as you send it:

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
const params = new URLSearchParams(window.location.search)

const cart = await cope.createCart({
  currency: "EUR",
  attribution: {
    utm_source: params.get("utm_source") || undefined,
    utm_medium: params.get("utm_medium") || undefined,
    utm_campaign: params.get("utm_campaign") || undefined,
  },
})
```

`attribution` on the webhooks carries only the nine keys in the table above. COPE stores the other keys you send in `attribution`, among them `landing_url`, `referrer` and `captured_at`, but does not return them there. The cart API also keeps `fbclid`, `gclid`, `ttclid` and `msclkid` when you send them in `attribution`. The SDK's `CartAttribution` type does not declare them. An `affiliate_id` or `promo_code` inside `attribution` is not applied: see [affiliate links](#affiliate-links).

An offer and a checkout from your server take no attribution.

## A second link or a returning buyer

A buyer who opens a product link, 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 and quantity intact. What the link they come back on does to that cart:

| What the cart carries | A later link that carries it | A later link that does not |
| - | - | - |
| UTM parameters and ad click ids | **Replaced as a whole:** the later link's set wins, and a parameter it leaves out is gone. | **Cleared.** |
| Your `metadata` and `external_reference` | **Replaced as a whole:** keys the later link leaves out are gone, not inherited. | **Kept.** Silence is not an instruction to empty the bag. |
| The affiliate link click | **Replaced** by the later link's code, when it is one of the seller's promoter codes. | **Kept** while the earlier click's 7-day window runs. |

* **Reloading the same page sends nothing again.** A tab delivers its link to the cart once; a reload of that tab does not deliver it a second time.
* **A 1-click checkout link starts a fresh cart on every visit**, so the order carries exactly that link's `metadata`, UTM parameters and click ids. Its affiliate click follows the seven-day rule: a link without `?aff=` can still take the seller's latest promoter click from the same browser.
* **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 what reached it **last**: usually the link opened most recently, not the one whose tab the buyer checked out in. Hand a buyer one link at a time.
* **The order keeps what the cart held when it was placed.** A link opened after the order exists changes nothing on that order.
* **If a value has to be on the order, put it on every link** that can reach that product: a later link without your `external_reference` leaves the earlier link's in place, but a later link without your UTM parameters clears them.

A later link changing a cart raises no event of its own, so the order events are where you read the final values.

## Affiliate links

Add `?aff=<code>` to a product link or a 1-click checkout link, with one of the seller's promoter codes, and COPE records the buyer's click on that promoter's link:

```txt theme={"theme":{"light":"github-light","dark":"github-dark"}}
https://app.cope.com/products/<product-id>?aff=partner42
```

* **`?ref=<code>` is read the same way** when the link carries no `aff`. A link that uses `ref` for anything else has that value read as an affiliate code, so do not use `ref` for your own purposes on a COPE link.
* **The last click counts, for seven days, per seller.** A later link with another of the seller's promoter codes replaces the earlier click; a link with none keeps the latest one for seven days after its click. A click for one seller never replaces another seller's.
* **A code that is not one of the seller's promoter codes is ignored.**
* **A click comes only from a link.** An `affiliate_id` or `promo_code` inside `attribution` is not applied, and UTM parameters and click ids are never read as an affiliate click.

**The click is not necessarily the promoter credited with the sale.** COPE decides which promoter is credited from the link click, a promoter's promo code, or a promoter's phone offer. `first_click_ref` on the order and payment webhooks and `affiliate` on [your thank-you page](#on-your-thank-you-page) each carry the raw affiliate code of the buyer's link click, whichever promoter was credited; [reading it back](#reading-it-back) says when each is left out.

## Offers and checkouts from your server

A cart you create with your secret API key, an [offer](https://docs.staging.cope-demo.com/checkout-sdk/overview.md#what-the-buyer-cannot-change-on-an-offer) or a [checkout from your server](https://docs.staging.cope-demo.com/checkout-sdk/server-checkout.md), keeps what you set:

* **Your `metadata` and `external_reference` cannot be changed** from the checkout page: a request that would change them is refused with `422 metadata_locked`. Re-sending the stored bag unchanged is accepted.
* **Attribution cannot be added.** A request from the checkout page that sends `attribution` is refused with `422 attribution_locked`, whatever it says, and creating a checkout from your server with an `attribution` member is refused with `400 invalid_request`. Such an order carries no UTM parameters, click ids or affiliate link click.

Carts started from a product link, a 1-click checkout link, a prefilled checkout or the SDK are not locked: they were created in the buyer's browser, so the rules for [a second link](#a-second-link-or-a-returning-buyer) decide what they carry.

## Reading it back

### On the webhooks

| Webhook event | Your data | Attribution |
| - | - | - |
| `cart.cart.created` | `metadata`, the bag the cart was created with | `attribution`; absent when the cart carries none |
| `cart.cart.abandoned` | `metadata` | `attribution` |
| `cart.order.created`, `cart.order.failed` | `metadata` | — |
| `cart.order.completed` | `metadata`, beside `cart`, the cart the order was bought from | `attribution`; `first_click_ref` |
| `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` | `attribution`; `first_click_ref` |
| `payment.failed`, `payment.attempt.failed` | `order.metadata` | — |

* **`attribution`** is an object holding the UTM parameters and click ids that were captured, and no other key: `{}` when none was. The field also allows `null`, so treat `null` and `{}` alike.
* **`first_click_ref`** is the affiliate code of the buyer's link click (see [affiliate links](#affiliate-links)). It is absent when there was none, when the buyer is the seller, and on a post-purchase upsell order. Treat absent and `null` alike. It is not necessarily the promoter credited with the sale.
* **`landing_url`, `referrer` and `captured_at` are not on any webhook.**
* `payment.failed` also declares a top-level `metadata`, which is never filled: read `order.metadata`.

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. So a refund carries the same reference and attribution as the sale it refunds. Use the reference to group them with your own record, and match each event to the order by `order.id`, which every one of these events carries.
* **An empty object means you attached nothing.** `metadata` is `{}` when you sent none. 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 metadata. Key your records on `order.id` and treat your own reference as a grouping handle.
* **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 code of the buyer's link click. Left out when there was none, and when the buyer is the seller. It is not necessarily the promoter credited with the sale. |
| `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 when it still fits, so your page knows. The whole bag is always on the order's webhooks. `affiliate` and `order` are never left out to save length.
* **Keep `order` and `affiliate` out of your own success URL.** COPE appends its parameters last and adds `affiliate` only when there is one, so a parameter of the same name in your URL makes the two indistinguishable.
* **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]`.
* **No UTM parameters or click ids are added.** A conversion tag on your thank-you page takes the order from `order`, and that order's attribution from the webhooks your server receives for it, not from the redirect.
* **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.

### Through the API

* [Retrieving an offer](https://docs.staging.cope-demo.com/checkout-sdk/overview.md#prices-payment-plans-and-offers) returns the `external_reference` and `metadata` it was created with, and the `order.id` once it is paid.
* `GET /v1/commerce/checkouts/{id}` returns the `external_reference` and `metadata` of a [checkout you created from your server](https://docs.staging.cope-demo.com/checkout-sdk/server-checkout.md), and its `order` once the buyer has paid.

No API returns a cart's or an order's metadata, or any order's attribution: read them from the webhooks. Once created, `metadata` and `external_reference` cannot be changed through the Commerce API: no operation updates them on an offer, a checkout or an order.

## Match the order by its ID

Anything in a link is visible in the buyer's address bar and can be edited there before they reach checkout, and a value your page sends with the publishable key comes from code running in the buyer's browser. So:

* **Never make an access decision from it.** Do not grant an entitlement, apply a price or unlock an account because `external_reference`, `metadata` or `attribution` says so: a buyer who changes one character in the URL would be granting it to themselves.
* **Match a paid order to your record by `order.id`** from the webhook: for an offer, the `order.id` that retrieving the offer returns; for a checkout from your server, the `order` that retrieving the checkout returns; for an order bought from a cart you created with the SDK, `cart.id` on `cart.order.completed`. An offer's bag cannot be changed by the buyer, but a link can imitate it.
* **Put nothing private in a link.** No email addresses, no internal notes, nothing you would not show the buyer. Use an opaque identifier that means something in your system and nothing outside it.
* **Treat what arrives on your thank-you page as buyer-controlled input.** Anyone can open your page with any parameters, so confirm the order by its `order` ID before acting on it.

## Test a link before you send it

A link whose bag broke a [limit](#limits), or whose parameters were not encoded, still checks the buyer out, so the first sign of a mistake would be orders without your reference. Check one generated link first:

1. Generate the link from code, with every value percent-encoded.
2. Open it in a browser that has no cart of its own for that product, such as a private window.
3. Read the `cart.cart.created` webhook it produces. `metadata` holds your keys and `attribution` your UTM parameters and click ids. A bag that was refused is missing from `metadata`, and a parameter COPE does not capture is missing from `attribution`; that is the only warning you get.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.