Skip to main content

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

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.

FieldTypeNullableDescription
amountintegerYesDiscount in minor units (e.g. cents). Mutually exclusive with percent. Minimum 1 when set.
percentintegerYesPercent off, 1–100. Mutually exclusive with amount.
durationstringYesHow long the discount applies on a recurring offered variant: once, repeating, or forever. Do not send it on a one-time variant.
duration_monthsintegerYesMonths 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 null on create or patch for no timer.
  • 0 returns 422.

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

FieldTypeNullableDescription
idstringNoServer-minted UUID.
product_idstringNoOffered product UUID — not the owner.
variant_idstringNoOffered variant UUID.
parent_idstringYesParent cross-sell UUID. null is a root.
titlestringYesOffer headline. Max 255. Checkout may fall back to the offered product name at runtime.
main_descriptionstringYesThank-you body.
descriptionstringYesStored override description. Checkout may fall back to the offered product description at runtime.
button_textstringYesCall-to-action label. Max 100.
amountintegerNoOffered variant list price in minor units (e.g. cents). GET-only.
currencystringNoISO-3 lowercase. Always the offered variant's currency. GET-only.
discountobjectYesNested discount, or null for list price. See Discount.
validity_minutesintegerYesThank-you / signed-URL window in minutes. null means no timer.
one_purchasebooleanNoWhen true, the signed checkout can be used once.
send_offerbooleanNoInclude this offer in the confirmation email. Runtime only sends roots.
activebooleanNoWritable on/off. Inactive rows still appear in the list.

Draft create, update, and the reorder envelope add:

FieldTypeNullableDescription
draft_versionintegerNoNew overlay version after the write. Minimum 1.
preview_urlstringNoTemporary signed checkout preview. Expires after about one hour. See Draft preview.