Product Variants
The Product Variants API lets you manage different variants of products in your store.
Overview
The Product Variants API provides endpoints to:
- List product variants
- Retrieve a product variant
- Create a product variant
- Update a product variant
- Archive a product variant
- Reorder product variants
- List product draft variants
- Retrieve a product draft variant
- Create a product draft variant
- Update a product draft variant
- Archive a product draft variant
- Reorder product draft variants
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.
| Status | Meaning |
|---|---|
published | Sellable. A variant on a product whose own status is draft is still published until it is archived. |
archived | End 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.
amountis the charged price in minor units (e.g. cents).1999is 19.99 PLN.old_priceis the struck-through compare-at price in major currency units.79is 79.00 PLN.
On update, set old_price to null to clear it.
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_untilis omitted, the variant stops selling onaccess_until. - An earlier
active_untilends sales sooner — for example, sales end on2026-10-10while buyers keep access until2027-03-30. - A later
active_untilis capped toaccess_until, and settingactive_untiltonullresets it toaccess_until. - When an update changes
access_untilwithout sendingactive_until, anactive_untilequal to the oldaccess_untilmoves to the new date. An earlieractive_untilis 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, andshipping_phone_requiredare true at checkout when either the product or the variant is true. Storedfalseon the variant does not turn off a producttrue.- Consent labels and
phone_tooltipinherit the product when the variant value isnull. A non-empty variant label wins.newsletter_consent_requiredfollows the variant only when the variant'snewsletter_consentlabel is set. - When the variant has
delivery_enabled: true, checkout uses only that variant'sdelivery_service,delivery_address_optional, anddelivery_options— it does not mix in the product's package. Otherwise it uses the product. newsletter_discounton the variant wins when it is set. Sendingnullclears 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: nullandcredits: nullinherit the product at checkout — they are not zero. Empty portal copy falls through to the product. show_net_priceand pay-what-you-want exist only on the variant.pay_what_you_wantis a nested object. Presence means it is on (enabledis alwaystrue).nullmeans it is off. Sendnullon update to turn it off and clear suggested, minimum, and text. That does not clearpurchase_button_text.- Recurring variants cannot enable pay-what-you-want — the call returns
422. purchase_button_textis a sibling ofpay_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.
| Field | Type | Nullable | Description |
|---|---|---|---|
enabled | boolean | No | Always true when this object is present. Send null on the parent field to turn it off — do not send enabled: false. |
suggested_amount | integer | Yes | Suggested amount in minor units (e.g. cents). |
minimum_amount | integer | Yes | Minimum amount in minor units. Must not exceed suggested_amount when both are set. |
text | string | Yes | Copy shown with the custom-amount field (max 100 characters). |
| Body | Error path |
|---|---|
Recurring variant with a non-null pay_what_you_want | pay_what_you_want |
pay_what_you_want.enabled is false | pay_what_you_want.enabled |
Both amounts set and minimum_amount greater than suggested_amount | pay_what_you_want.minimum_amount |
| Negative or non-integer amounts | that 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
| Field | Type | Nullable | Description |
|---|---|---|---|
variant_id | string | No | Target sibling variant UUID. |
text | string | Yes | Checkout copy shown with the upsell (max 250 characters). |
| Body | Error path |
|---|---|
| Unknown UUID, another product, another store, self, duplicate, not yet published, archived, or a target that already has upsells | upsells.{i}.variant_id |
| This variant is already a target of a sibling and the new list is non-empty | upsells |
| Unknown keys on an item | upsells.{i}.<key> |
text longer than 250 characters | upsells.{i}.text |
Item is not an object, or variant_id is missing or not a UUID | upsells.{i} / upsells.{i}.variant_id |