Public Alpha. Easel is in early development, so expect rough edges.

Easel

← API Documentation

Products API

Manage products in your store. A product has a name, a status, an optional
description, and one or more variants that hold the price and inventory.

Related resources:

Endpoints

Product sites:


List Products

Retrieve a paginated list of products for your store, oldest first.

Endpoint: GET /api/v1/products/

Scope Required: products:read

Query Parameters

Parameter Type Description
after string Return products after this public ID
before string Return products before this public ID
limit integer Number of products to return (default 10, max 100)
search string Search products by product name or variant name; a product with no variants yet matches by its name
status string Filter by product status (draft, active, archived)
external_source string With external_id, find the one record imported from that reference; other filters and the cursor are ignored
external_id string Record's ID in external_source

Every page comes oldest first, whether you page forward with after or back
with before. A search pages the same way, in the same order as the plain
list, and lists each product once however many of its variants match; it
combines with status. The response reports has_more, which is true
when another page lies beyond this one in the direction you're paging.
after and before cannot be used together.

Request

curl -X GET "https://studio.easel.engineering/api/v1/products/?status=active&limit=20" \
  -H "Authorization: Bearer your_api_key_here"

Response

Status: 200 OK

{
  "data": [
    {
      "id": "pro_d0b6fmv28q6vn14peun0",
      "name": "Premium T-Shirt",
      "slug": "premium-t-shirt",
      "status": "active",
      "description": "High-quality cotton t-shirt",
      "delivery": "physical",
      "made_to_order": false,
      "created_at": "2023-01-01T10:00:00Z",
      "updated_at": "2023-01-01T10:15:00Z",
      "external_source": null,
      "external_id": null,
      "handling_days": null,
      "weight_grams": 1500,
      "taken_down_at": null
    }
  ],
  "has_more": true
}

Error Responses

Invalid query parameter

Status: 400 Bad Request

Query parameter validation errors (an invalid status, a limit over 100, or
using after and before together) return INVALID_REQUEST_ERROR, with the
offending parameter named in the error message:

{
  "error": "Invalid 'status' parameter: must be 'active', 'draft', or 'archived'",
  "code": "INVALID_REQUEST_ERROR"
}

Failed to list products

Status: 500 Internal Server Error

{
  "error": "Internal server error",
  "code": "INTERNAL_ERROR"
}

Create Product

Create a new product in your store. New products are created with the draft
status. Delivery defaults to physical; pass delivery to create a digital
product.

A product created without options has no variants yet; add them with
Create Product Variant. Send
options and price to make the product's variants with it, as the
dashboard does: see Options.

Endpoint: POST /api/v1/products/

Scope Required: products:write

Request Body

{
  "name": "Premium T-Shirt",
  "description": "High-quality cotton t-shirt"
}

Fields

Field Type Required Description
name string Yes Product name
slug string No The end of the product's storefront URL, unique in the store. Omit it to make one from the name (see Slug)
description string No Product description
delivery string No physical (default), digital, or gift_card
made_to_order boolean No Keep the product on sale once its stock runs out (default false); see Made to Order
external_source string No System the record was imported from; requires external_id (see External References)
external_id string No Record's ID in that system; requires external_source
handling_days integer|null No Business days the product takes to ship, from 0 to 60, when it's slower than its site's handling time (see Delivery Estimates). Omit or null to use the site's
weight_grams integer|null No What one unit weighs packed, in grams, from 0 to 1,000,000, which its variants share unless they set their own (see Weight). Omit or null to weigh nothing toward shipping
options array No Options the product starts with, each with its type and values. Every combination of their values becomes a variant (see Options)
price integer With options Price of each variant the options make, in the store's smallest currency unit, 0 or more. Only sent with options
sites array No IDs of the store's sites the product shows on. Omit it to show the product on every site; send [] for none (see Product Sites). An ID of another store's site returns INVALID_REQUEST_ERROR

Request

curl -X POST "https://studio.easel.engineering/api/v1/products/" \
  -H "Authorization: Bearer your_api_key_here" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: unique-request-id-12345" \
  -d '{
    "name": "Premium T-Shirt",
    "description": "High-quality cotton t-shirt"
  }'

Optional Headers:

  • Idempotency-Key: Unique string to prevent duplicate product creation (see Idempotency)

Response

Returns the created product.

Status: 201 Created

{
  "id": "pro_d0b6fmv28q6vn14peun0",
  "name": "Premium T-Shirt",
  "slug": "premium-t-shirt",
  "status": "draft",
  "description": "High-quality cotton t-shirt",
  "delivery": "physical",
  "made_to_order": false,
  "created_at": "2023-01-01T10:00:00Z",
  "updated_at": "2023-01-01T10:00:00Z",
  "external_source": null,
  "external_id": null,
  "handling_days": null,
  "weight_grams": 1500,
  "taken_down_at": null
}

Error Responses

Invalid JSON body

Status: 400 Bad Request

{
  "error": "Invalid JSON body",
  "code": "INVALID_JSON"
}

Missing required field

Status: 400 Bad Request

{
  "error": "Product name is required",
  "code": "MISSING_FIELD"
}

Invalid slug

Status: 400 Bad Request

{
  "error": "Slug must be lowercase letters, digits and single hyphens, at most 100 characters",
  "code": "INVALID_FIELD"
}

Slug already used by another product of the store

Status: 409 Conflict

{
  "error": "Another product already has this slug",
  "code": "DUPLICATE"
}

Options without a price

Status: 400 Bad Request

{
  "error": "Price is required",
  "code": "MISSING_FIELD"
}

Invalid options or price

A price sent without options, a negative price, more than 3 options, an option
without a name or values, a type other than text or color, two options
or two values of one option with the same name (ignoring case), more than 30
values, a name or value over 256 bytes, a color option's value without a
hex or with one that isn't # and six hex digits, or a text option's
value with a hex. error says which, and the field is named as
options[0].values[1].hex.

Status: 400 Bad Request

{
  "error": "Another option has this name",
  "code": "INVALID_FIELD"
}

Options make more than 100 variants

Nothing is created, not even the product.

Status: 409 Conflict

{
  "error": "A product can have at most 100 variants",
  "code": "TOO_MANY_VARIANTS"
}

Failed to create product

Status: 500 Internal Server Error

{
  "error": "Internal server error",
  "code": "INTERNAL_ERROR"
}

Get Product

Retrieve a single product by its public ID.

Endpoint: GET /api/v1/products/{product_id}/

Scope Required: products:read

Path Parameters

Parameter Type Description
product_id string Product public ID (e.g., pro_d0b6fmv28q6vn14peun0)

Request

curl -X GET "https://studio.easel.engineering/api/v1/products/pro_d0b6fmv28q6vn14peun0/" \
  -H "Authorization: Bearer your_api_key_here"

Response

Status: 200 OK

{
  "id": "pro_d0b6fmv28q6vn14peun0",
  "name": "Premium T-Shirt",
  "slug": "premium-t-shirt",
  "status": "active",
  "description": "High-quality cotton t-shirt",
  "delivery": "physical",
  "made_to_order": false,
  "created_at": "2023-01-01T10:00:00Z",
  "updated_at": "2023-01-01T10:15:00Z",
  "external_source": null,
  "external_id": null,
  "review_count": 12,
  "rating_average": 4.25,
  "handling_days": null,
  "weight_grams": 1500,
  "taken_down_at": null
}

Error Responses

Product not found

Status: 404 Not Found

{
  "error": "Resource not found",
  "code": "NOT_FOUND"
}

Failed to get product

Status: 500 Internal Server Error

{
  "error": "Internal server error",
  "code": "INTERNAL_ERROR"
}

Update Product

Update an existing product's name, slug, description, status, or
made-to-order setting.

Endpoint: PUT /api/v1/products/{product_id}/

Scope Required: products:write

Path Parameters

Parameter Type Description
product_id string Product public ID (e.g., pro_d0b6fmv28q6vn14peun0)

Request Body

{
  "name": "Premium T-Shirt - Updated",
  "slug": "premium-t-shirt",
  "status": "active",
  "description": "Updated high-quality cotton t-shirt"
}

Fields

Field Type Required Description
name string Yes Product name
status string Yes Product status: draft, active, or archived
slug string No The end of the product's storefront URL. Leave it out to keep the current one (see Slug)
description string No Product description
made_to_order boolean No Keep the product on sale once its stock runs out. Leave it out to keep the current setting; see Made to Order
handling_days integer|null No Business days the product takes to ship, from 0 to 60. Like description, it is replaced: omit it or send null to use the site's handling time
weight_grams integer|null No What one unit weighs, in grams. Like handling_days, it is replaced: omit it or send null to weigh nothing

Request

curl -X PUT "https://studio.easel.engineering/api/v1/products/pro_d0b6fmv28q6vn14peun0/" \
  -H "Authorization: Bearer your_api_key_here" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: unique-request-id-67890" \
  -d '{
    "name": "Premium T-Shirt - Updated",
    "status": "active",
    "description": "Updated high-quality cotton t-shirt"
  }'

Optional Headers:

  • Idempotency-Key: Unique string to prevent duplicate product updates (see Idempotency)

Response

Returns the updated product.

Status: 200 OK

{
  "id": "pro_d0b6fmv28q6vn14peun0",
  "name": "Premium T-Shirt - Updated",
  "slug": "premium-t-shirt",
  "status": "active",
  "description": "Updated high-quality cotton t-shirt",
  "delivery": "physical",
  "made_to_order": false,
  "created_at": "2023-01-01T10:00:00Z",
  "updated_at": "2023-01-01T11:30:00Z",
  "external_source": null,
  "external_id": null,
  "handling_days": null,
  "weight_grams": 1500,
  "taken_down_at": null
}

Error Responses

Invalid JSON body

Status: 400 Bad Request

{
  "error": "Invalid JSON body",
  "code": "INVALID_JSON"
}

Missing required field

Status: 400 Bad Request

{
  "error": "Product name is required",
  "code": "MISSING_FIELD"
}

Invalid status

Status: 400 Bad Request

{
  "error": "Status must be 'draft', 'active', or 'archived'",
  "code": "INVALID_FIELD"
}

Invalid handling days

Status: 400 Bad Request

{
  "error": "Handling days must be between 0 and 60",
  "code": "INVALID_FIELD"
}

Invalid slug

Status: 400 Bad Request

{
  "error": "Slug must be lowercase letters, digits and single hyphens, at most 100 characters",
  "code": "INVALID_FIELD"
}

Slug already used by another product of the store

Status: 409 Conflict

{
  "error": "Another product already has this slug",
  "code": "DUPLICATE"
}

Easel took the product down

Easel's staff took the product down as illegal or against Easel's terms,
which made it a draft; its taken_down_at is set (see
Takedowns). It can be edited, which asks Easel to review it
again, but status can't be active until Easel restores it.

Status: 409 Conflict

{
  "error": "Easel took this down, so it can't be published or shown until Easel restores it",
  "code": "TAKEN_DOWN"
}

Product not found

Status: 404 Not Found

{
  "error": "Resource not found",
  "code": "NOT_FOUND"
}

Failed to update product

Status: 500 Internal Server Error

{
  "error": "Internal server error",
  "code": "INTERNAL_ERROR"
}

Duplicate Product

Copy a product and its variants into a new draft product. A product with
more than 100 variants, from before the limit, can't be copied.

The copy's variants start with no stock, digital file, SKU or barcode, which
all belong to the original's items.
The copy shows on the same sites as the original.

Endpoint: POST /api/v1/products/{product_id}/duplicate/

Scope Required: products:write

Path Parameters

Parameter Type Description
product_id string Product public ID (e.g., pro_d0b6fmv28q6vn14peun0)

Request

curl -X POST "https://studio.easel.engineering/api/v1/products/pro_d0b6fmv28q6vn14peun0/duplicate/" \
  -H "Authorization: Bearer your_api_key_here" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: unique-request-id-13579"

Response

Status: 201 Created

{
  "id": "pro_new1fmv28q6vn14peun0"
}

Error Responses

Product not found

Status: 404 Not Found

{
  "error": "Resource not found",
  "code": "NOT_FOUND"
}

Too many copies

Status: 400 Bad Request

{
  "error": "Invalid input",
  "code": "INVALID_FIELD"
}

Product has more than 100 variants

Status: 409 Conflict

{
  "error": "A product can have at most 100 variants",
  "code": "TOO_MANY_VARIANTS"
}

Easel took the product down

A copy could be published, so a product whose taken_down_at is set
can't be duplicated until Easel restores it (see Takedowns).

Status: 409 Conflict

{
  "error": "Easel took this down, so it can't be published or shown until Easel restores it",
  "code": "TAKEN_DOWN"
}

Failed to duplicate product

Status: 500 Internal Server Error

{
  "error": "Internal server error",
  "code": "INTERNAL_ERROR"
}

List Product Sites

Retrieve the sites a product shows on. Like List Sites,
it isn't paginated and leaves allowed_shipping_countries empty.

Endpoint: GET /api/v1/products/{product_id}/sites/

Scope Required: products:read

Request

curl -X GET "https://studio.easel.engineering/api/v1/products/pro_d0b6fmv28q6vn14peun0/sites/" \
  -H "Authorization: Bearer your_api_key_here"

Response

Status: 200 OK

{
  "data": [
    {
      "id": "sit_d0b6fmv28q6vn14peun0",
      "name": "My Store",
      "copyright_name": "My Store",
      "slug": "my-store",
      "status": "active",
      "currency": "USD",
      "language": "en",
      "languages": [],
      "domain_host": null,
      "allowed_shipping_countries": [],
      "cancel_window_minutes": 15,
      "gift_notes_enabled": true,
      "gift_note_max_length": 200,
      "created_at": "2023-01-01T10:00:00Z",
      "updated_at": "2023-01-01T10:00:00Z",
      "wishlist_sale_alerts": true,
      "delivery_times": {
        "handling_days": 1
      }
    }
  ]
}

Add Product Site

Show a product on one more of the store's sites. Adding a site the product
is already on changes nothing.

Endpoint: PUT /api/v1/products/{product_id}/sites/{site_id}/

Scope Required: products:write

Request

curl -X PUT "https://studio.easel.engineering/api/v1/products/pro_d0b6fmv28q6vn14peun0/sites/sit_d0b6fmv28q6vn14peun0/" \
  -H "Authorization: Bearer your_api_key_here" \
  -H "Idempotency-Key: unique-request-id-97531"

Response

Status: 204 No Content

A product or site the store doesn't have returns 404 Not Found with
NOT_FOUND.


Remove Product Site

Take a product off one site. Removing a site the product isn't on changes
nothing.

Endpoint: DELETE /api/v1/products/{product_id}/sites/{site_id}/

Scope Required: products:write

Request

curl -X DELETE "https://studio.easel.engineering/api/v1/products/pro_d0b6fmv28q6vn14peun0/sites/sit_d0b6fmv28q6vn14peun0/" \
  -H "Authorization: Bearer your_api_key_here"

Response

Status: 204 No Content


Product Object

Fields

Field Type Description
id string Product public ID
name string Product name
slug string The end of the product's storefront URL, /products/{slug}/, unique in the store. Always present. See Slug
status string Product status: draft, active, archived
description string|null Product description
delivery string physical, digital, or gift_card
made_to_order boolean Whether the product stays on sale once its stock runs out
created_at string ISO 8601 timestamp
updated_at string ISO 8601 timestamp
external_source string|null System the record was imported from (see External References)
external_id string|null Record's ID in that system
review_count integer Number of the product's published reviews. Read-only
rating_average number|null Mean star rating of those reviews, to two decimals, or null with none. Read-only
handling_days integer|null Business days the product takes to ship, or null to use its site's handling time
weight_grams integer|null What one unit weighs, in grams, or null for nothing. See Weight
taken_down_at string|null ISO 8601 timestamp of when Easel took the product down, or null while it hasn't. Read-only. See Takedowns

Product Sites

A store with several sites shows each product only on the sites it's on:
its page, the catalog, search, collections, the homepage's Best Sellers and
New Arrivals, related products and the sitemap all leave it out elsewhere,
and a link to it there returns not found. A new product goes on every site
unless Create Product sends sites, and a copy goes on
the same sites as its original. Change them with
Add Product Site and
Remove Product Site.

A site's Best Sellers rank its own products by their units sold in the whole
store, on any site.

Product Status Values

  • draft - Product is being configured, not visible to customers
  • active - Product is available for purchase
  • archived - Product is no longer available but preserved for historical data

Delivery

Delivery is set on the product and shown on its variants. It
defaults to physical; pass "delivery": "digital" when creating a
product to make a digital product. Delivery is fixed at creation,
so the update endpoint does not change it.

Digital products are delivered as a download instead of being shipped. Attach
the downloadable file to a variant with the
Digital Files endpoints.

Slug

A product's storefront page lives at /products/{slug}/, as in
/products/premium-t-shirt/, under the site's language prefix for a
translated page (/fr/products/premium-t-shirt/). A variant opens with
?variant={variant_id}; without one, or with a variant since deleted, the
page redirects to the first variant that isn't sold out. A product has one
slug in every language.

A slug is lowercase letters (from any alphabet), digits and single hyphens,
at most 100 characters; uppercase letters are lowercased. It is unique in
the store. A new product, or a copy, gets one made from its name, with
accents dropped and a number added when another product has it
(premium-t-shirt-2). Renaming a product keeps its slug, so shared links
keep working; change slug to change the URL. Links to an old slug then
stop working, as old slugs aren't kept.

The older storefront URL by public ID,
/products/{product_id}/variants/{variant_id}/, still works and
redirects to the slug URL, whatever the slug is now.

Ratings

review_count and rating_average count only reviews everyone can see on
the storefront: not hidden by the store and not by a banned or shadowbanned
author. They update as soon as a review is posted, edited, deleted, hidden,
or shown again. See Reviews. A site with reviews
off doesn't show them, but the product still reports them here.

Takedowns

Easel hosts your store's content, so under the EU Digital Services Act its
staff may take a product down that's illegal or against Easel's terms (see
Moderation). Taking it down makes it a
draft and sets taken_down_at. Until Easel restores it, setting
status to active, or duplicating it, returns 409 Conflict with
TAKEN_DOWN. You can still edit it, which asks Easel to review it again,
or reply to the email Easel sent the store's owners.

That email is the statement of reasons, and the dashboard keeps it under
Settings › Activity › Easel decisions. The activity log's taken_down
event names the category in reason. When Easel restores the product,
taken_down_at goes back to null and its status stays draft until
you publish it.

Made to Order

A made-to-order product is made after it's bought, so it never sells out.
Set made_to_order to true when creating or updating a physical product.
It defaults to false, and an update that leaves it out keeps the current
setting.

Stock still counts the units you have ready. While a variant has stock,
the storefront shows it as in stock and sells from it, though it never warns
that only a few are left. Once a variant's total_inventory reaches 0, the
storefront labels it "Made to Order" instead of "Out of Stock", and shoppers
can still add it to their cart and check out in any quantity.

Paid orders take units off stock until it reaches 0, and stock never goes
below 0: units ordered beyond your stock are made for that order, not owed
back to stock. To restock, update the variant's total_inventory as usual.

made_to_order has no effect on digital products, which never run out.

Delivery Estimates

A physical product's page shows an estimated delivery date range, worked out
from its site's delivery times: the
business days the shop takes to ship an order, then the transit time of the
cheapest shipping rate to the buyer's country,
naming a faster rate when there is one. Set handling_days on a product
that takes longer to make or pack than the site's usual, such as one made
to order; it replaces the site's handling time for that product only.
Transit times always come from the rates.

Weight

weight_grams is what one unit of a physical product weighs, packed, in
grams. Its variants weigh the same unless they set their own
weight_grams (see Product Variants). An order's
parcel weighs what its items do, and each started kilogram past the first
adds to what its shipping rate
charges. Items without a weight, digital products, and gift cards weigh
nothing. The dashboard takes and shows weights in grams or kilograms.

A gift_card product sells gift cards: each unit bought
issues a card worth the price paid for it, emailed with its code once the
order is paid. Give it one variant per amount, such as $25, $50, and $100.
Gift cards are neither shipped nor stocked, take no discount codes, and an
order line buys at most 20 of them.

Options

Send options with a new product to make its variants at once, as the
dashboard does. Each option has a name, a type, and its values, in
order:

curl -X POST "https://studio.easel.engineering/api/v1/products/" \
  -H "Authorization: Bearer your_api_key_here" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: unique-request-id-54321" \
  -d '{
    "name": "Premium T-Shirt",
    "price": 2500,
    "options": [
      {
        "name": "Color",
        "type": "color",
        "values": [
          {"value": "Red", "hex": "#ff0000"},
          {"value": "Blue", "hex": "#0000ff"}
        ]
      },
      {
        "name": "Size",
        "type": "text",
        "values": [
          {"value": "S", "hex": null},
          {"value": "M", "hex": null},
          {"value": "L", "hex": null}
        ]
      }
    ]
  }'

The options, their values and every combination of them as a variant are
made in one step: here six variants, "Red, S", "Red, M", "Red, L",
"Blue, S", "Blue, M" and "Blue, L". A variant is named by its values in the
order the options were sent, which is the order they're created in, joined by
", ". Each variant costs price and starts with no stock, SKU or barcode;
set those with
Update Product Variant. List
the variants with
List Product Variants and the
options with List Product Options.

Field Type Required Description
name string Yes Option name, such as Size, unique among the product's options (ignoring case)
type string Yes text, or color for an option whose values show shoppers a color swatch
values array Yes The option's values, in order, 1 to 30 of them
values[].value string Yes Value name, such as S or Red, unique in the option (ignoring case)
values[].hex string|null Yes The value's swatch, # and six hex digits such as #ff0000, for a color option; null for a text option

Every value of a color option has a hex, so its swatches show on the
storefront as soon as the product is active, and a text option's values have
none. The hex is stored in lowercase.

A product has at most 3 options of at most 30 values each, and at most 100
variants. Options whose combinations would pass 100, such as 11 colors in 10
sizes, are refused with 409 Conflict and TOO_MANY_VARIANTS before anything
is made, so the product isn't created either.

Adding a value to an option later makes its new combinations; see
Create Product Option Value.