Products
The Products API lets you manage products in your store.
Overview
The Products API provides endpoints to:
- List products
- Retrieve product details
- Retrieve a product draft
- Update a product draft
- Discard a product draft
- Create new products
- Update products
- Update product styles
- Update product settings
- Upload product media
- Update product media
- Publish products
- Unpublish products
- Download a product file
Host a file with Upload media. The same calls cover checkout and Creator-page files. Upload product media hosts a product file in a single multipart request.
Checkout and waitlist input definitions live under Custom Fields: list, get, create, update, and delete.
Checkout order bumps live under Order Bumps: list, get, create, update, delete, and reorder.
After-purchase cross-sells live under Cross-sells: list, get, create, update, delete, and reorder.
Checkout branding palettes live under Branding Themes.
Attach a theme with branding_theme_id on
Update product styles.
Unpublished overlay variants live under Product Variants: list, get, create, update, archive, and reorder.
Status and has_draft
A product uses draft in two independent ways:
statusis whether the product is on sale —draftorpublished.has_draftis whether an unpublished overlay exists. Buyers still see the live product.
A published product can have has_draft: true. A product with status: draft can have has_draft: false.
Live list and get return the live product plus
has_draft. They do not include draft_version, draft_updated_at, or preview_url.
Get product draft returns the overlay when one exists, or the
live product when it does not. draft_version is present on that response even when
has_draft is false — send it on every draft write. preview_url is always present —
see Draft preview.
Draft preview
Get product draft and update product draft
return preview_url. It is a temporary signed link to a checkout preview of the overlay —
or of the live product when has_draft is false. It needs no Authorization header, so
it can be opened straight in a browser. It expires about an hour after the read, so
request the draft again for a fresh one instead of storing it. Once it has expired — or if
the link is altered or its signature is dropped — the page returns 404.
Append ?lang=en or ?lang=pl to render the preview in that language. lang is the one
parameter you can add without invalidating the link; any other value is ignored.
checkout_url is always the live checkout, not the overlay.
Create,
update,
archive, and
reorder of a draft variant return a
fresh product preview_url. Nested draft-variant list
and get use the live variant shape and
do not include preview_url. Draft custom-field
create and
update, draft order-bump
create,
update, and
reorder, draft cross-sell
create,
update, and
reorder, and draft
styles, settings,
and media writes, also return a fresh preview_url.
Draft custom-field delete,
order-bump delete, and
cross-sell delete return 204 with
no body.
Live writes while a draft exists
When has_draft is true, these live writes return 409 and change nothing:
- Update product
- Update product styles
- Update product settings
- Update product media
- Create product variant
- Update product variant
- Archive product variant
- Reorder product variants
- Create custom field (live nested)
- Update custom field (live nested)
- Delete custom field (live nested)
- Create order bump (live nested)
- Update order bump (live flat)
- Delete order bump (live flat)
- Reorder order bumps (live nested)
- Create cross-sell (live nested)
- Update cross-sell (live flat)
- Delete cross-sell (live flat)
- Reorder cross-sells (live nested)
The body is a flat { "message": "…" } — the same envelope as other errors, with no
error code. Edit the overlay instead (update product draft,
nested draft-variant endpoints, nested /draft/order-bumps, nested /draft/cross-sells,
and nested /draft/styles, /draft/settings, /draft/media). To drop the overlay,
discard the draft. Product fields stored only on the
draft are dropped. A course connect or disconnect stored by
Update Product Draft is dropped with the draft
and does not change the live product. Discarding does not roll
draft_version back. Publish and unpublish
persist the overlay as the live product and drop it. Only publish applies a stored
course connect or disconnect — unpublish drops it. After a successful call,
has_draft is false.
Upload media and
upload product media are not blocked by has_draft —
hosting a file does not change the gallery or a Creator page.
Promotion codes and other resources are not blocked by has_draft.
Active plan required
Putting a product on sale requires the store to have an active Easytools plan. Without one, the store can still create, edit and keep drafts, but it cannot make anything live and cannot change anything that is already live.
These live writes are refused, each returning 422:
- Create product with
status: published— no product is created. Omittingstatuscreates a draft.status: draftis always accepted. - Publish product — the product stays a draft. Publishing a product that is already published is refused in the same way.
- Update product when the product is currently published, because the change would go live immediately. Updating a draft is always accepted.
- Update product styles,
settings, and
media when the product is currently published. Overlay
/draft/styles,/draft/settings, and/draft/mediawrites are always accepted. - Create product variant when the parent product is currently published. Adding a variant to a draft is always accepted.
- Update product variant when the parent product is currently published. Updating a variant of a draft is always accepted.
A refused call creates and changes nothing, and returns a body with a message and no
errors key:
{
"message": "Product cannot be published without an active Easytools plan"
}
These endpoints also return 422 for ordinary validation errors, with a field-keyed
errors object next to the message. The presence of errors is what tells the two
apart. The plan-required body is a flat { "message": "…" } with no errors key.
Repeating the request returns the same error until the store owner activates a plan; changing the request does not resolve it. Create products as drafts and publish them once the plan is active.
Nothing else is gated. Nested custom-field writes,
upload media, and
upload product media are not plan-gated.
Unpublish product works regardless, so a live product can always
be taken down; every read endpoint and every overlay write keeps working too. Live
writes while an overlay exists return 409 — see
Live writes while a draft exists.
Downloadable files
A product can carry up to 5 files, delivered to the buyer after purchase. Each entry in
files has a name, a url, and — on reads — a download_url.
url is the file's stored location, and the value to send back when writing files. For a
file uploaded to your store it is not publicly readable: requesting it directly returns
403. For a file attached by link it is that URL exactly as it was provided.
To fetch a file's contents, follow its download_url. It is a temporary signed link that
needs no Authorization header, so it can be opened straight in a browser. It expires
about an hour after the read, so request the product again for a fresh one instead of
storing it. Integrations that previously downloaded a file by fetching files[].url need
to follow download_url instead. See
Download Product File.
download_url appears only on reads. Write requests take name and url.
Gallery and logo hosting is a different write — Upload Media or Upload Product Media — and does not attach a downloadable file.
On get product draft and the nested draft-variant reads, a file
that exists only on the overlay — attached by a draft write and not yet published — has
download_url: null, because there is no stored copy to serve yet. It gets one once the
draft is published. Files that were already live carry their download_url as usual.
A variant can carry its own files, which replace the product's for buyers of that variant. They use the same file object — see Product Variants.
URL fields
Every URL a product or variant accepts — image_url, redirect_url, webhook_url,
terms_url, policy_url, cta_url, contact_website_url, settings logo_url /
logo_square_url / thank-you button_url / waitlist redirect_url, gallery
file_url / thumbnail_url, and each file's url —
must start with http:// or https://
and include a host. Almost nothing else is restricted: percent-encoding, query strings,
fragments, explicit ports and non-ASCII characters in the path are all accepted, so a
presigned storage link or a link with encoded spaces can be sent exactly as it is:
https://cdn.example.com/files/my%20file.pdf
https://bucket.s3.eu-central-1.amazonaws.com/f.pdf?X-Amz-Signature=abc&X-Amz-Expires=900
https://example.com/thanks#top
https://example.com:8443/thanks
https://example.com/a?tags=a,b
Anything else — a different scheme, a protocol-relative URL, a bare https:// with no
host — is rejected with 422. Spaces and the characters <, > and " must be
percent-encoded rather than sent raw.
The message is the same in every case, keyed by the field that failed:
{
"message": "The given data was invalid.",
"errors": {
"redirect_url": [
"Enter a valid link starting with http:// or https://"
]
}
}
Maximum lengths differ per field and are listed with each field on
Create product,
Update product,
Update product variant,
and Update product settings
(logo_url / logo_square_url and thank-you / waitlist URLs). Gallery
file_url / thumbnail_url are on
Update product media.
Product images
The image at image_url is the product's cover. It is downloaded and stored when a
product is created or when image_url is sent on an update — including by
update product draft, which re-hosts the image at the moment
of the draft write rather than at publish. The image_url returned in responses, on
the draft as well as the live product, points at that stored copy, so reading a
product back returns a different URL than the one that was sent.
On update product and update product draft,
sending image_url changes the cover only. Other images on the product are left
unchanged. Send null to clear the cover only.
Gallery membership is a separate write — Update Product Media.
Omitting the cover URL from gallery items removes that gallery row; image_url is
not rewritten.
Checkout appearance
Checkout colours, logos, thank-you copy, recovery, and the gallery are nested
sections. They are not fields on create or
update. Sending styles, settings, or media on those bodies
is ignored.
Read them on Get Product and Get Product Draft
with ?include=styles,settings,media. The keys are always present; the value is
null unless that token is requested. When requested, styles and settings are
objects even if nothing has been stored yet; media is a bare array (possibly
empty) — not the { items, pagination } list envelope. List omits
the keys. Create, update, publish, and unpublish responses leave the three keys
null. Checkout and waitlist field definitions use the same include pattern —
see Custom Fields.
Write them with:
- Update Product Styles
- Update Product Settings
- Upload Media (host only — the same URL for checkout and Creator-page files; does not attach the file)
- Upload Product Media (host only, in one multipart request — does not change the gallery)
- Update Product Media (replace-all gallery)
Live section patches return 409 while has_draft is true. Hosting a file does not.
Overlay writes use /products/{id}/draft/styles, /draft/settings, and
/draft/media with required draft_version. There is no draft twin of the host
upload. Unknown keys on a section patch return 422.
Cover stays image_url — see Product images.
Branding theme
styles.branding_theme_id is the UUID of a store-owned
branding theme, or null. It is always present when
include=styles is requested.
Send branding_theme_id on Update Product Styles
(live or /draft/styles):
| Body | Result |
|---|---|
{ "branding_theme_id": "f670a72a-7a46-4853-9ebb-91ddf691358e" } | Attach. Copy each of the six theme colours (brand_color, background_color, text_color, card_background_color, card_text_color, border_color) onto the product only when that theme value is non-null. Copy logo_url / logo_square_url onto settings the same way. Existing product colours and logos stay when the matching theme field is null. main_background_color, main_border_color, price_text_color, and shadow tokens are not copied. |
{ "branding_theme_id": null } | Detach. Keep current colour tokens and logos. |
A colour, border_radius, or shadow field | Detach. Colour tokens change as sent. |
Only darkmode_enabled | Leave the theme attached. |
Non-null branding_theme_id plus a colour, border_radius, or shadow field | 422 on branding_theme_id. |
| Unknown theme UUID | 422 on branding_theme_id. |
theme_id is an unknown key (422). Create themes with
Branding Themes first.
Colours
Each colour field accepts null (clear), the literal transparent, or # plus 3–6
hex digits (#abc, #aabbcc). Values are stored and returned as sent — they are not
expanded or lowercased. Anything else returns 422 on that field.
shadow_opacity is an integer 0–100. When this patch sets shadow_color to a hex
value, shadow_opacity must be present in the same request (or already set after
the merge). transparent or null shadow_color may send shadow_opacity: null.
border_radius is an integer 0–100; 0 turns it off.
Sending a colour, border_radius, or a shadow field detaches any currently attached
theme. Sending only darkmode_enabled leaves the theme attached. See
Branding theme.
Settings
show_logo controls whether the checkout logo is visible. Omit to leave it unchanged.
When no settings have been stored yet, it reads as true.
Nested thank_you, waitlist_thank_you, and confirmation_email sparse-merge; send
null on that object to clear its fields. Product footer_text, redirect_url,
redirect_time, add_redirect_params, and show_confetti stay on the product —
they are not duplicated under thank_you. Waitlist show_confetti / redirect_*
live on waitlist_thank_you only.
checkout_recovery.enabled: null inherits the store default from
Get store / Update store.
coupon: null clears the coupon. amount and percent are mutually exclusive —
422 if both are set, or if the object has neither and is not null. amount is
in minor units (e.g. cents). currency is present on GET when amount is set;
sending it on write returns 422. The store coupon has no currency key.
Logos are URL-in (logo_url, logo_square_url). Host the file first with
Upload Media or
Upload Product Media, then send the returned file_url.
| Body | Error path |
|---|---|
| Unknown top-level or nested key | that path |
| Colour not matching the hex contract | that colour field |
shadow_opacity out of 0–100, or missing while this patch sets a hex shadow_color | shadow_opacity |
border_radius out of 0–100 | border_radius |
checkout_recovery.coupon.amount and .percent both set | checkout_recovery.coupon |
checkout_recovery.coupon object with neither amount nor percent (and not null) | checkout_recovery.coupon |
checkout_recovery.coupon.currency on write | checkout_recovery.coupon.currency |
| Thank-you or waitlist URL not HTTP | that field |
Gallery items[].type not image or video | items.{i}.type |
Gallery items[].file_url missing or not an HTTP URL | items.{i}.file_url |
Prepare content_type or size rejected | content_type or size on Upload Media |
Upload Product Media bad type or missing file | that field |
Non-null branding_theme_id plus a colour / border_radius / shadow key | branding_theme_id |
Unknown branding_theme_id | branding_theme_id |
Max lengths: thank_you.heading / button_text 250; thank_you.text 3000;
thank_you.button_url 255; waitlist heading/button_text 250, text 3000,
button_url 255, redirect_url 1000; confirmation_email.heading 255;
confirmation_email.content 3000. Coupon percent 1–100. Coupon amount integer
≥ 0 minor units.
Styles Object
Checkout appearance tokens. Present on reads when include=styles is requested;
never null in that case.
| Field | Type | Nullable | Description |
|---|---|---|---|
brand_color | string | Yes | Brand colour. See Colours. |
main_background_color | string | Yes | Page background colour. |
background_color | string | Yes | Description-panel colour. Distinct from main_background_color. |
text_color | string | Yes | Body text colour. |
card_background_color | string | Yes | Card background colour. |
card_text_color | string | Yes | Card text colour. |
border_color | string | Yes | Border colour. |
main_border_color | string | Yes | Main border colour. transparent turns it off. |
price_text_color | string | Yes | Price text colour. |
shadow_color | string | Yes | Shadow colour. transparent turns it off. |
border_radius | integer | Yes | Corner radius, 0–100. |
shadow_opacity | integer | Yes | Shadow opacity, 0–100. |
darkmode_enabled | boolean | No | Whether dark mode is on. Defaults to false when no styles have been stored. |
branding_theme_id | string | Yes | Store-owned branding theme UUID, or null. See Branding theme. |
Draft styles writes add draft_version and preview_url — see
Draft preview.
Settings Object
Checkout logos, thank-you pages, confirmation email, and recovery. Present on reads
when include=settings is requested; never null in that case.
| Field | Type | Nullable | Description |
|---|---|---|---|
logo_url | string | Yes | Checkout logo URL. |
logo_square_url | string | Yes | Square logo URL. |
show_logo | boolean | No | Whether the checkout logo is visible. Defaults to true when no settings have been stored. |
thank_you | object | No | Post-purchase thank-you. See Thank You Object. |
waitlist_thank_you | object | No | Waitlist thank-you. See Waitlist Thank You Object. |
confirmation_email | object | No | Confirmation email. See Confirmation Email Object. |
checkout_recovery | object | No | Abandoned-checkout recovery. See Checkout Recovery Object. |
Draft settings writes add draft_version and preview_url.
Thank You Object
| Field | Type | Nullable | Description |
|---|---|---|---|
heading | string | Yes | Heading, max 250 characters. |
text | string | Yes | Body, max 3000 characters. |
button_text | string | Yes | Button label, max 250 characters. |
button_url | string | Yes | Button URL, max 255 characters. |
Waitlist Thank You Object
| Field | Type | Nullable | Description |
|---|---|---|---|
heading | string | Yes | Heading, max 250 characters. |
text | string | Yes | Body, max 3000 characters. |
show_confetti | boolean | No | Confetti on the waitlist thank-you page. Distinct from product show_confetti. |
redirect_url | string | Yes | Post-waitlist redirect URL, max 1000 characters. Distinct from product redirect_url. |
redirect_time | integer | Yes | Redirect delay in seconds. |
add_redirect_params | boolean | No | Include parameters in the redirect URL. Defaults to true when no settings have been stored. |
button_text | string | Yes | Button label, max 250 characters. |
button_url | string | Yes | Button URL, max 255 characters. |
Confirmation Email Object
| Field | Type | Nullable | Description |
|---|---|---|---|
heading | string | Yes | Email heading, max 255 characters. |
content | string | Yes | Email body, max 3000 characters. |
Checkout Recovery Object
| Field | Type | Nullable | Description |
|---|---|---|---|
enabled | boolean | Yes | null inherits the store default. |
coupon | object | Yes | Discount offered in the recovery email. null means none. See Checkout Recovery Coupon Object. |
Checkout Recovery Coupon Object
amount and percent are mutually exclusive. amount is in minor units (e.g. cents)
of the product currency.
| Field | Type | Nullable | Description |
|---|---|---|---|
amount | integer | Yes | Fixed discount in minor units (e.g. cents). Mutually exclusive with percent. |
percent | integer | Yes | Percent discount, 1–100. Mutually exclusive with amount. |
currency | string | Yes | Product currency (lowercase ISO code). Present on reads when amount is set. Not accepted on write. |
When amount is set, a GET returns:
{
"amount": 500,
"percent": null,
"currency": "usd"
}
Media Item Object
Gallery membership in display order. On GET with include=media, the value is a bare
array of these items (possibly []). Cover remains image_url.
| Field | Type | Nullable | Description |
|---|---|---|---|
file_url | string | No | Gallery locator. Same key as Upload Media. |
type | string | No | image or video. A host upload can also return file; that value is not accepted here. |
thumbnail_url | string | Yes | Thumbnail URL. On write, omit to keep the matching file_url's stored thumbnail; send JSON null to clear it. |
Easytools attachments
A Love wall, a product legal footer, and connected courses are read on
Get Product and Get Product Draft
with include=wall_id, include=footer_id, and include=player. Those
tokens mix with custom_fields, waitlist_custom_fields, styles,
settings, and media. Unknown tokens are ignored.
Without the token, the key is absent. null means the token was sent and
nothing is attached. A failed lookup stays 200. That field alone is
{ "message": "The Easytools attachment could not be resolved." }. Other
requested fields still return. One failed course or redirect UUID fails
all of player, not one entry in player_ids.
List products does not accept these tokens. Create,
update, publish, and unpublish responses omit the keys. Write them on
Update Product and
Update Product Draft. disconnect is not a
read field.
A draft read can differ from the live product. See Get Product Draft.
| Token | When omitted | Nothing attached | Attached |
|---|---|---|---|
wall_id | Key absent | null | UUID string |
footer_id | Key absent | null | UUID string |
player | Key absent | null | Player Object |
A failed wall_id, footer_id, or player is the message object instead
of a UUID or a player object.
Player Object
Connected courses, returned only with include=player. This is not the
write object on Update Product.
| Field | Type | Nullable | Description |
|---|---|---|---|
redirect_id | string | Yes | Thank-you redirect UUID, or null when courses are connected and nothing redirects. When this is a UUID it is one of player_ids. |
player_ids | array | No | Connected course UUIDs, in connection order, with duplicates dropped. |
null for the whole player value means nothing is connected and there is no redirect.
A redirect plus another course:
{
"redirect_id": "a4e8c1d2-7b39-4f6a-9c05-1e2d8b4a6f70",
"player_ids": [
"a4e8c1d2-7b39-4f6a-9c05-1e2d8b4a6f70",
"c3b1e9f4-2a68-4d70-8e15-9f0a6c2d4b83"
]
}
Courses and no redirect:
{
"redirect_id": null,
"player_ids": [
"c3b1e9f4-2a68-4d70-8e15-9f0a6c2d4b83"
]
}
Checkout form
A product's checkout can collect a delivery address, offer parcel delivery, show consent labels, and offer a discount with a newsletter opt-in. These fields appear on get, create, update, and the matching draft endpoints. They are not on the list.
A variant can store its own values for the shipping, consent, and newsletter fields — see Checkout overrides. Delegation fields exist only on the product.
has_shipping and delivery_enabled are separate:
has_shippingcollects a delivery address.delivery_enabledturns on parcel delivery. That requires adelivery_servicecarrier slug and a complete Delivery Options Object.
delivery_service is one of dpd, fedex, ups, gls, poczta, inpost,
inpostkurier, orlen, dhl, meest, ambroexpress, furgonetka, deligoo,
xpressdelivery, pallex, swiatprzesylek.
Consent fields are labels. A non-empty string shows that consent; null turns it off.
delegation_label and phone_tooltip are at most 250 characters; consent labels are at
most 500.
On create, omitted booleans default to false except delivery_address_optional, which
defaults to true. Omitted labels, delivery_service, delivery_options, and
newsletter_discount default to null. On update, omit a field to leave it unchanged.
Send null on newsletter_discount to clear that section. Send null on
delivery_options to delete the stored package only when the resulting
delivery_enabled is false (already false, or send delivery_enabled: false in the
same request). Sending delivery_options: null while delivery_enabled stays true
returns 422. A sent delivery_options object replaces the stored package in full —
omitted keys inside it become null.
newsletter_discount.currency is read-only (the product's currency, present when
amount is set). Sending currency on create or update returns 422.
| Body | Error path |
|---|---|
delivery_service is not a known carrier slug | delivery_service |
delivery_enabled: true without delivery_service | delivery_service |
Resulting delivery_enabled is true without a complete delivery_options (all five keys non-null), including delivery_options: null while delivery stays on | delivery_options |
width_cm / length_cm / height_cm below 1, or weight_kg below 0.1 | that nested path |
newsletter_discount.amount and newsletter_discount.percent both set | newsletter_discount |
duration: repeating without duration_months | newsletter_discount.duration_months |
amount set with duration: forever | newsletter_discount.duration |
newsletter_discount.currency on create or update | newsletter_discount.currency |
Invoice, tax, portal, waitlist, and QR
A product can store invoice remarks, tax settings, self-service portal copy, extra contact details, checkout footer copy, waitlist flags, and QR credits. These fields appear on get, create, update, and the matching draft endpoints. They are not on the list.
A variant can store its own tax, portal, and credits values — plus pay-what-you-want
and show_net_price. Reads return what is stored, not the checkout combination.
See Checkout overrides. Waitlist
flags, contact extras, footer_text, qr_code_info, and reset_credits exist only
on the product.
A product vat_rate, flat_rate, gtu_code, or exempt_tax_kind that is null
inherits the matching store default at checkout — see
Tax defaults.
vat_rate is a string — "23", "zw", or "np" — not a number. gtu_code is an
integer from 1 to 13 as stored, not a GTU_01 label. flat_rate is one of
17, 15, 14, 12.5, 12, 10, 8.5, 5.5, 3, 2. tax_category is one of
saas, ebook, course, webinar, digital, consulting, physical, exempt.
It is distinct from a variant's tax_behavior.
Portal copy (dashboard_header, long_description, cta_text, cta_url) is the
self-service portal, not the product description. Product long_description is at
most 25,000 characters; a variant's is at most 500. cta_url and
contact_website_url follow URL fields. contact_website_url is
this product's contact site — distinct from the store website_url.
credits: null means credits are off, not zero. The maximum is 4294967295; a larger
value returns 422. Omitting footer_text on create persists as an empty string
(""), not JSON null.
On create, omitted strings, tax values, and credits default to null (footer_text
to ""). Omitted waitlist flags and reset_credits default to false. On update,
omit a field to leave it unchanged. Send null on a nullable string, tax value, or
credits to clear it.
| Body | Error path |
|---|---|
vat_rate is not a valid rate | vat_rate |
gtu_code is not an integer from 1 to 13 | gtu_code |
flat_rate is not in the allowed set | flat_rate |
tax_category is not a known slug | tax_category |
cta_url / contact_website_url is not an HTTP URL | that field |
credits is negative or greater than 4294967295 | credits |
An invalid vat_rate uses the same 422 envelope. The message on that field may be
in Polish:
{
"message": "The given data was invalid.",
"errors": {
"vat_rate": [
"Niepoprawna stawka VAT"
]
}
}
Delivery Options Object
Parcel package for delivery_enabled. Present on reads when a package is stored; null
otherwise.
| Field | Type | Nullable | Description |
|---|---|---|---|
width_cm | integer | Yes | Package width in centimetres. Minimum 1. |
length_cm | integer | Yes | Package length in centimetres. Minimum 1. |
height_cm | integer | Yes | Package height in centimetres. Minimum 1. |
weight_kg | number | Yes | Package weight in kilograms. Minimum 0.1. |
contents | string | Yes | Package contents description (max 255 characters). |
Newsletter Discount Object
Discount offered when the buyer opts into the newsletter. amount and percent are
mutually exclusive. amount is in minor units (e.g. cents) of the product currency.
| Field | Type | Nullable | Description |
|---|---|---|---|
amount | integer | Yes | Fixed discount in minor units (e.g. cents). Mutually exclusive with percent. |
percent | integer | Yes | Percent discount, 1–100. Mutually exclusive with amount. |
duration | string | No | How long the discount applies: once, repeating, or forever. forever cannot be combined with amount. |
duration_months | integer | Yes | Months the discount repeats. Required when duration is repeating. Minimum 1. |
currency | string | Yes | Product currency (lowercase ISO code). Present on reads when amount is set. Not accepted on create or update. |