Cross-sells
A cross-sell is an after-purchase offer shown on a product's thank-you page and confirmation email. It points at another product and variant — possibly the same product, a recurring variant, or a different currency.
Path {productId} is the owner checkout product. product_id and
variant_id on the resource are the offered product and variant. The owner
id is not on the object.
Product GET does not embed cross-sells — they are read and written only through the endpoints below.
Cross-sells are not variant upsells (same-product upgrades on a variant, no own routes) and not order bumps (add-to-order offers before the buyer pays).
Overview
- List cross-sells
- Retrieve a cross-sell
- Create a cross-sell
- Update a cross-sell
- Delete a cross-sell
- Reorder cross-sells
Each write has a live form and a draft twin. Live list, create, and reorder are
nested under the owner product. Live get, update, and delete use the flat
/cross-sells/{id} path. Draft twins are fully nested under
/products/{productId}/draft/cross-sells. There is no live nested
GET /products/{productId}/cross-sells/{id} and no flat /draft/cross-sells/{id}.
Live writes while a draft exists
When the owner product has has_draft: true, live nested and flat writes return
409 and change nothing. Edit the overlay instead
(/products/{productId}/draft/cross-sells). See
Live writes while a draft exists.
Draft writes require draft_version from
Get Product Draft. Create, update, and reorder
on the overlay also return a fresh preview_url. Overlay
delete returns 204 with no body.
Amount and currency
amount and currency are always the offered variant's list price — not an
override, and not the figure after discount. They are GET-only. Sending either
on create or update returns 422.
discount: null means list price. It is not free.
Discount
discount is a nested object or null. Omit it or send null on create for no
discount. PATCH with discount: null clears it.
| Field | Type | Nullable | Description |
|---|---|---|---|
amount | integer | Yes | Discount in minor units (e.g. cents). Mutually exclusive with percent. Minimum 1 when set. |
percent | integer | Yes | Percent off, 1–100. Mutually exclusive with amount. |
duration | string | Yes | How long the discount applies on a recurring offered variant: once, repeating, or forever. Do not send it on a one-time variant. |
duration_months | integer | Yes | Months the discount repeats. Required 1–120 when duration is repeating. Rejected unless duration is repeating. |
amount cannot be combined with duration=forever. duration or
duration_months without amount or percent returns 422. Recurring offered
variants are allowed; one-time variants may carry amount or percent without
duration.
Validity minutes
validity_minutes is the thank-you / signed-checkout window in minutes.
- Omit it on create to store 15.
- Send
nullon create or patch for no timer. 0returns422.
Parent and depth
parent_id is the parent cross-sell UUID. null (or omit on create) is a
root. Reparent with Update a cross-sell, then
reorder if display order should change — reorder does
not rewrite parent_id.
Offers nest at most 3 levels (root, child, grandchild). A parent_id that
would nest deeper, that is missing, that belongs to another owner, that is the
row itself, or that is a descendant (a cycle) returns 422 on parent_id.
An inactive parent hides its children on the thank-you page. Lists still include those children.
Delete
Delete removes the row. Children are promoted to the
deleted offer's parent first — they are not deleted, and they become extra roots
only when the deleted offer was itself a root. A later GET returns 404. The
same id cannot be reused.
Delete is not hide — use active: false to keep the offer off the thank-you
page without removing it.
Active, one purchase, and send offer
active is the writable on/off toggle. Omit it on create to store true.
false hides the offer at thank-you but the row stays in the list.
one_purchase limits the signed checkout to a single use. Omit it on create to
store true.
send_offer includes the offer in the confirmation email. Omit it on create to
store true. Only roots (parent_id null) with send_offer: true and
active: true are sent.
Display order
The cross-sell object has no order field — array order of
list is display order. Rewrite it with
Reorder Cross-sells. Create appends at the end.
Offered product and variant
Create and update return 422 when product_id or variant_id is not in the
store, when the variant does not belong to product_id, or when discount /
parent / length rules fail. See Discount and
Parent and depth.
These are allowed: a different currency from the owner, delivery on the offered product, a recurring offered variant, a duplicate offered pair, offering the owner product itself, and any number of rows.
Live writes also return 422 when the owner product is not on Stripe. Draft
writes are accepted either way, but publishing a draft while the product is
still not on Stripe does not carry the offers over to the live product.
Incomplete items
Every cross-sell this API returns has an offered product and variant. An offer
still being set up in the dashboard, with either one unset, is not returned by
list or get and is not counted in total. Create requires both product_id
and variant_id.
Cross-sell Object
| Field | Type | Nullable | Description |
|---|---|---|---|
id | string | No | Server-minted UUID. |
product_id | string | No | Offered product UUID — not the owner. |
variant_id | string | No | Offered variant UUID. |
parent_id | string | Yes | Parent cross-sell UUID. null is a root. |
title | string | Yes | Offer headline. Max 255. Checkout may fall back to the offered product name at runtime. |
main_description | string | Yes | Thank-you body. |
description | string | Yes | Stored override description. Checkout may fall back to the offered product description at runtime. |
button_text | string | Yes | Call-to-action label. Max 100. |
amount | integer | No | Offered variant list price in minor units (e.g. cents). GET-only. |
currency | string | No | ISO-3 lowercase. Always the offered variant's currency. GET-only. |
discount | object | Yes | Nested discount, or null for list price. See Discount. |
validity_minutes | integer | Yes | Thank-you / signed-URL window in minutes. null means no timer. |
one_purchase | boolean | No | When true, the signed checkout can be used once. |
send_offer | boolean | No | Include this offer in the confirmation email. Runtime only sends roots. |
active | boolean | No | Writable on/off. Inactive rows still appear in the list. |
Draft create, update, and the reorder envelope add:
| Field | Type | Nullable | Description |
|---|---|---|---|
draft_version | integer | No | New overlay version after the write. Minimum 1. |
preview_url | string | No | Temporary signed checkout preview. Expires after about one hour. See Draft preview. |