Create Product Variant
Create a new product variant for a product in your store.
Adding a variant to a product that is currently published requires an active Easytools
plan, because the variant goes live immediately. If the store has none, the call returns
422 and no variant is created. Adding a variant to a draft product is always accepted.
See Active plan required.
When the product has has_draft: true, the call returns 409 and no variant is created.
Create the variant on the overlay with
Create Product Draft Variant, or
discard the draft first. See
Live writes while a draft exists.
Request
POST /products/{id}/variants
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Product UUID |
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
type | string | Yes | Variant type ("one_time" or "recurring") |
name | string | No | Variant name (max 255 characters) |
recurring_options | object | No | Recurring options (required if type is "recurring") |
recurring_options.interval | string | No | Recurring interval ("day", "week", "month", "year") |
recurring_options.interval_count | integer | No | Number of intervals between charges |
amount | integer | Yes | Price in minor units (minimum 0) |
trial_period_days | integer | No | Trial period in days (min: 0, recurring variants only) |
active_cycles | integer | No | Number of billing cycles (min: 1, recurring only) |
is_hidden | boolean | No | Hide variant from checkout (default: false) |
quantity | integer | No | Inventory limit (min: 0, null = unlimited) |
has_quantity | boolean | No | Enable quantity selector (default: false) |
old_price | integer | No | Struck-through compare-at price in major currency units (79 = 79.00 PLN). Min: 0. Unlike amount, which is in minor units (e.g. cents). See Price units. |
active_from | string | No | Start selling date (ISO 8601) |
active_until | string | No | End selling date (ISO 8601, must be >= active_from). With access_until set, defaults to access_until and can't be later — see Access expiry modes |
active_days | integer | No | Days of access after purchase (relative-expiry mode, min: 1) |
access_until | string | No | Fixed access-expiry date (ISO 8601). The variant stops selling on this date at the latest — see Access expiry modes |
tax_behavior | string | No | Tax handling ("inclusive" or "exclusive") |
has_shipping | boolean | No | Collect a delivery address for this variant. Defaults to false. Checkout ORs this with the product — see Checkout overrides. |
delivery_enabled | boolean | No | Enable parcel delivery on this variant. Defaults to false. When true, delivery_service and a complete delivery_options object are required. |
delivery_service | string | No | Carrier slug. See Checkout form. Required when delivery_enabled is true. |
delivery_address_optional | boolean | No | Whether the delivery address is optional. Defaults to true. |
delivery_options | object | No | Parcel package. See Delivery Options Object. Required in full when delivery_enabled is true. |
shipping_phone_required | boolean | No | Require a phone number with the delivery address. Defaults to false. |
phone_tooltip | string | No | Helper text for the shipping phone field (max 250 characters). |
mandatory_consent | string | No | Mandatory checkout consent label (max 500 characters). Omit or null inherits the product. |
optional_consent | string | No | Optional checkout consent label (max 500 characters). |
digital_consent | string | No | Digital-content consent label (max 500 characters). |
newsletter_consent | string | No | Newsletter opt-in label (max 500 characters). |
newsletter_consent_required | boolean | No | Whether the newsletter opt-in is required on this variant. Defaults to false. |
newsletter_discount | object | No | Discount offered with newsletter opt-in. See Newsletter Discount Object. Do not send currency. |
vat_rate | string | No | VAT rate as a string (23, zw, np). Omit stores null (inherit the product at checkout, not 0%). See Checkout overrides. |
gtu_code | integer | No | GTU code 1–13 as stored, not a GTU_01 label. Omit stores null. |
flat_rate | number | No | Flat-rate tax percent: 17, 15, 14, 12.5, 12, 10, 8.5, 5.5, 3, or 2. Omit stores null. |
exempt_tax_kind | string | No | Exemption basis when VAT is zw (max 255 characters). |
tax_category | string | No | Tax category: saas, ebook, course, webinar, digital, consulting, physical, or exempt. Distinct from tax_behavior. |
dashboard_header | string | No | Self-service portal header (max 250 characters). Empty or omit inherits the product. |
long_description | string | No | Self-service portal body (max 500 characters on a variant). Distinct from product description. |
cta_text | string | No | Self-service portal call-to-action label (max 50 characters). |
cta_url | string | No | Self-service portal call-to-action URL (max 255 characters). |
credits | integer | No | QR credits stored on this variant. Omit or null inherits the product, not zero. Minimum 0, maximum 4294967295 (422 if exceeded). |
show_net_price | boolean | No | Whether checkout shows the net price. Defaults to false. |
pay_what_you_want | object | No | Pay-what-you-want settings. See Pay What You Want Object. Omit stores null (off). Cannot be enabled on a recurring variant. |
purchase_button_text | string | No | Checkout purchase-button label (pay-what-you-want and lead magnet). Sibling of pay_what_you_want, not nested (max 25 characters). |
upsells is not accepted on create. The response still includes upsells: []. Attach
upsells with Update Product Variant after the target
siblings exist. See Upsells.
Sending custom_fields here is ignored. Set the checkout override with the nested
Custom Fields endpoints after the variant exists.
cta_url must start with http:// or https:// and include a host. See
URL fields.
Example Request
curl -X POST "https://cart.easy.tools/api/v1/products/0632bef5-c308-42cd-9cba-89a7a4f722bb/variants" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"type": "one_time",
"name": "250g Package",
"amount": 1999,
"old_price": 29,
"quantity": 100,
"has_quantity": true
}'
curl -X POST "https://cart.easy.tools/api/v1/products/0632bef5-c308-42cd-9cba-89a7a4f722bb/variants" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"type": "recurring",
"name": "Monthly Subscription - 250g",
"recurring_options": {
"interval": "month",
"interval_count": 1
},
"amount": 1799,
"trial_period_days": 14,
"active_cycles": 12,
"tax_behavior": "inclusive"
}'
Override shipping on this variant (stored true; omit the flag to store false):
curl -X POST "https://cart.easy.tools/api/v1/products/0632bef5-c308-42cd-9cba-89a7a4f722bb/variants" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"type": "one_time",
"name": "250g Package",
"amount": 1999,
"has_shipping": true
}'
Pay-what-you-want on a one-time variant. Amounts are minor units of the product currency:
curl -X POST "https://cart.easy.tools/api/v1/products/0632bef5-c308-42cd-9cba-89a7a4f722bb/variants" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"type": "one_time",
"name": "Pay what you want",
"amount": 0,
"pay_what_you_want": {
"enabled": true,
"suggested_amount": 4900,
"minimum_amount": 1000
}
}'
Response
Success Response (201)
Returns the created product variant.
{
"id": "d91c65aa-b221-4aa4-b626-cf0d8a198749",
"status": "published",
"type": "one_time",
"recurring_options": null,
"name": "250g Package",
"amount": 1999,
"currency": "usd",
"checkout_url": "https://cart.easy.tools/checkout/premium-coffee?plan=price_123"
}
{
"id": "e903c6d6-163e-4080-a99a-4ee5a1e4f17a",
"status": "published",
"type": "recurring",
"recurring_options": {
"interval": "month",
"interval_count": 1
},
"name": "Monthly Subscription - 250g",
"amount": 1799,
"currency": "usd",
"checkout_url": "https://cart.easy.tools/checkout/premium-coffee?plan=price_456"
}
Response Fields
| Field | Type | Nullable | Description |
|---|---|---|---|
id | string | No | Unique identifier for the product variant (UUID) |
status | string | No | Variant lifecycle (published or archived). See Status. |
type | string | No | Variant type ("one_time" or "recurring") |
recurring_options | object | Yes | Recurring options (null for one-time variants) |
recurring_options.interval | string | No | Recurring interval ("day", "week", "month", "year") |
recurring_options.interval_count | integer | No | Number of intervals between charges |
name | string | Yes | Variant name |
amount | integer | No | Price in minor units |
currency | string | No | Lowercase ISO currency code (inherited from the product) |
checkout_url | string | Yes | Direct checkout URL for this variant. Null until the variant is synced to Stripe. |
The response contains the full variant. See Get Product Variant for the complete list of fields; the examples above are abbreviated.
Error Responses
Not Found Error (404)
Returned when the UUID does not exist, belongs to another store, or points at an archived
product. Variants can only be added to draft and published products.
{
"message": "Product with ID 0632bef5-c308-42cd-9cba-89a7a4f722bb not found"
}
Conflict Error (409 — Unpublished Draft)
Returned when an unpublished overlay exists. No variant is created. Create it on the overlay, or discard the draft first.
{
"message": "This product has an unpublished draft. Publish, unpublish, or delete the draft before making live changes."
}
Validation Error (422 Unprocessable Entity)
{
"message": "The given data was invalid.",
"errors": {
"type": [
"This field is required."
],
"recurring_options": [
"The recurring options field is required when type is recurring."
],
"amount": [
"This field is required."
]
}
}
Sellability fields use the same envelope. See Checkout form and Checkout overrides. Pay-what-you-want and tax fields use it too — a recurring variant cannot enable pay-what-you-want:
{
"message": "The given data was invalid.",
"errors": {
"pay_what_you_want": [
"Pay-what-you-want is not available on recurring variants."
]
}
}
Publishing Not Allowed (422 — No Active Plan)
Returned when the parent product is currently published and the store has no active Easytools plan. No variant is created. Activate a plan and retry — repeating the call as it stands returns the same error. Draft products are unaffected.
This body carries a message and no errors key, so a 422 from this endpoint is
distinguished by whether errors is present.
{
"message": "Product cannot be published without an active Easytools plan"
}