Skip to main content

Product Variants

The Product Variants API lets you manage different variants of products in your store.

Overview​

The Product Variants API provides endpoints to:

Checkout custom-field overrides for a variant live under Custom Fields. An empty stored array means inherit the product checkout fields. See Collections. Sending custom_fields on create or update is ignored — use the nested collection endpoints.

Live create, update, archive, and reorder return 409 when the parent product has has_draft: true. Unpublished overlay variants are only reachable under /products/{productId}/draft/variants — they do not appear on the live list. See Live writes while a draft exists.

Create, update, archive, and reorder of a draft variant return a fresh product preview_url. Nested list and get use the live variant shape and do not include preview_url or draft_version. See Draft preview.

Status​

Every variant carries status. It is independent of product status (draft vs published) and of is_hidden.

StatusMeaning
publishedSellable. A variant on a product whose own status is draft is still published until it is archived.
archivedEnd of life. Not sellable. The variant is kept and can be fetched by ID. There is no /unarchive.

Lists and nested variants on a product omit archived variants. Get Product Variant still returns them, labelled status: archived. There is no ?status=archived list filter.

is_hidden only controls whether the variant appears on the checkout page. A hidden variant stays published and must still be included when reordering. Archive does not change is_hidden.

Price units​

amount and old_price use opposite units.

  • amount is the charged price in minor units (e.g. cents). 1999 is 19.99 PLN.
  • old_price is the struck-through compare-at price in major currency units. 79 is 79.00 PLN.

On update, set old_price to null to clear it.

warning

Sending old_price: 7900 when you meant 79.00 PLN stores and displays 7900 PLN on checkout.

Immutable price fields​

amount, type, currency, tax_behavior, and recurring_options (including nested interval and interval_count) are set at create and cannot be changed.

Sending any of those fields on update product variant or update product draft variant returns 422. Nothing is changed — including other fields in the same body.

Stripe does not allow changing the amount on a published price, so the API cannot honour it either. To charge a different amount, archive the existing variant and create a new one — or archive and create a draft variant when you are editing an overlay.

Display order​

When sort is omitted, list and draft list return variants in the store's display order. Rewrite that order with Reorder Product Variants or Reorder Product Draft Variants. The variant object has no order field — response array order is display order.

Active plan required​

Creating or updating a variant of a published product requires the store to have an active Easytools plan, because the change goes live immediately. Create product variant and Update product variant return 422 without one, and nothing is created or changed. Variants of a draft product can always be created and updated. See Active plan required.

Archive and reorder are not plan-gated.

Downloadable files​

A variant can carry up to 5 of its own files. When it has any, buyers of that variant receive them instead of the product's files; when it has none, they fall back to the product's.

Variant files use the same file object as product files — url is the stored location you send back when writing, and reads add a temporary download_url that returns the contents. See Downloadable files and Download Product File.

Access expiry modes​

A variant can limit how long buyers keep access in one of two mutually-exclusive modes:

  • Relative — active_days: buyers keep access for N days after purchase.
  • Fixed — access_until: buyers keep access until a fixed calendar date, no matter when they bought.

The API does not enforce mutual exclusivity. If both fields are set, access_until takes precedence at fulfilment.

A variant with access_until stops selling on that date at the latest, so access that has already expired can't be bought. active_until follows these rules:

  • When active_until is omitted, the variant stops selling on access_until.
  • An earlier active_until ends sales sooner — for example, sales end on 2026-10-10 while buyers keep access until 2027-03-30.
  • A later active_until is capped to access_until, and setting active_until to null resets it to access_until.
  • When an update changes access_until without sending active_until, an active_until equal to the old access_until moves to the new date. An earlier active_until is kept.

Checkout overrides​

A variant can store its own shipping, consent, and newsletter values. Reads and writes return what is stored on that variant, not the effective checkout combination.

Checkout then combines the variant with the product:

  • has_shipping, delivery_enabled, and shipping_phone_required are true at checkout when either the product or the variant is true. Stored false on the variant does not turn off a product true.
  • Consent labels and phone_tooltip inherit the product when the variant value is null. A non-empty variant label wins. newsletter_consent_required follows the variant only when the variant's newsletter_consent label is set.
  • When the variant has delivery_enabled: true, checkout uses only that variant's delivery_service, delivery_address_optional, and delivery_options — it does not mix in the product's package. Otherwise it uses the product.
  • newsletter_discount on the variant wins when it is set. Sending null clears the variant discount so checkout uses the product's. There is no way to force a variant to have no discount while the product still has one.

Delegation fields (show_delegation, delegation_label, delegable_if_multiple) exist only on the product.

Field names, nested objects, create defaults, and 422 rules match Checkout form. Omitting sellability fields on create stores false / null (delivery_address_optional defaults to true). On update, null on a consent label, phone_tooltip, or newsletter_discount clears that variant override. delivery_options: null / delivery_service: null clear the override only when the resulting delivery_enabled on this variant is false.

Tax, portal copy, and credits follow the same stored-vs-checkout split:

  • Reads return the values stored on this variant. vat_rate: null and credits: null inherit the product at checkout — they are not zero. Empty portal copy falls through to the product.
  • show_net_price and pay-what-you-want exist only on the variant.
  • pay_what_you_want is a nested object. Presence means it is on (enabled is always true). null means it is off. Send null on update to turn it off and clear suggested, minimum, and text. That does not clear purchase_button_text.
  • Recurring variants cannot enable pay-what-you-want — the call returns 422.
  • purchase_button_text is a sibling of pay_what_you_want, not nested. It is also the lead-magnet purchase-button label.

Waitlist flags, contact extras, footer_text, qr_code_info, and reset_credits exist only on the product.

Field names, nested objects, create defaults, and 422 rules for tax and portal match Invoice, tax, portal, waitlist, and QR, except variant long_description is at most 500 characters.

Pay What You Want Object​

Present on reads when pay-what-you-want is on; null otherwise. Amounts are integer minor units (e.g. cents) of the variant currency. There is no nested currency key.

FieldTypeNullableDescription
enabledbooleanNoAlways true when this object is present. Send null on the parent field to turn it off — do not send enabled: false.
suggested_amountintegerYesSuggested amount in minor units (e.g. cents).
minimum_amountintegerYesMinimum amount in minor units. Must not exceed suggested_amount when both are set.
textstringYesCopy shown with the custom-amount field (max 100 characters).
BodyError path
Recurring variant with a non-null pay_what_you_wantpay_what_you_want
pay_what_you_want.enabled is falsepay_what_you_want.enabled
Both amounts set and minimum_amount greater than suggested_amountpay_what_you_want.minimum_amount
Negative or non-integer amountsthat amount field

Upsells​

A variant can list other published variants of the same product as checkout upgrades. upsells is always present on a full variant — [] when there are none. It is not an ?include= token. List, get-by-id, nested product variants[], nested draft variants[], and create/update responses all include it.

Each item has variant_id (the target variant's UUID) and optional text (checkout copy, max 250 characters). HTML markup in text is stripped. Array order is display order; there is no order field on the item.

Update Product Variant and Update Product Draft Variant replace the list in full. Omit upsells to leave it unchanged. Send [] to clear it.

Create does not accept upsells. The create response still returns upsells: []. Attach the list after the target siblings exist.

A listed target cannot itself have upsells, and a variant that already appears in a sibling's upsells cannot gain a non-empty list. Hidden published targets are allowed. Archived variants and draft variants that have not been published yet are not. Archiving a variant removes it from any sibling's upsells and clears its own.

Live update still returns 409 while the product has has_draft: true. See Live writes while a draft exists.

upsells is not order bumps and not cross-sells.

Upsell Object​

FieldTypeNullableDescription
variant_idstringNoTarget sibling variant UUID.
textstringYesCheckout copy shown with the upsell (max 250 characters).
BodyError path
Unknown UUID, another product, another store, self, duplicate, not yet published, archived, or a target that already has upsellsupsells.{i}.variant_id
This variant is already a target of a sibling and the new list is non-emptyupsells
Unknown keys on an itemupsells.{i}.<key>
text longer than 250 charactersupsells.{i}.text
Item is not an object, or variant_id is missing or not a UUIDupsells.{i} / upsells.{i}.variant_id