Store, Catalog, Inventory
All endpoints below are under https://your-openshopy-domain/api/public/v1.
Store info
GET /store
Scope: store:read (works with publishable or secret key)
Response:
{
"data": {
"id": "8e2b...",
"name": "Acme Shop",
"country": "PL",
"default_currency": "PLN",
"currencies": ["PLN", "USD", "EUR"],
"exchange_rates": { "PLN": 1, "USD": 0.25, "EUR": 0.23 },
"default_language": "pl",
"languages": ["pl", "en"],
"prices_include_tax": true,
"contact_email": "hello@acme.shop",
"company": { "name": "Acme Sp. z o.o.", "tax_id": "PL1234567890", "address": "..." },
"payment": {
"methods": ["bank_transfer"],
"bank_transfer": {
"account_holder": "Acme Sp. z o.o.",
"bank_name": "mBank",
"iban": "PL00 0000 0000 0000 0000 0000 0000",
"swift": "BREXPLPWMBK",
"payment_due_days": 7
}
}
}
}Note: the store only ever supports bank transfer as a payment method; there is no card/online-payment integration anywhere in the API.
Products
GET /products
Scope: products:read
Query params:
| Param | Type | Description |
|---|---|---|
page, limit | int | pagination |
status | draft|active|archived | Secret keys only; publishable keys always see only active products regardless of this param |
q | string | searches title and slug (ILIKE) |
tag | string | products whose tags array contains this value |
product_type | string | exact match |
collection_id | uuid | products belonging to this collection |
ids | comma-separated uuids | filter to specific product ids (invalid uuids silently dropped) |
sort | string | created_at|updated_at|title, prefix with - for descending; default -created_at; any other value falls back to created_at |
currency | PLN|USD|EUR | convert displayed prices; defaults to store default currency |
Response: { "data": [Product...], "meta": {page,limit,total,total_pages} }
Product object:
{
"id": "6f2e...",
"title": "Ceramic Mug",
"slug": "ceramic-mug",
"description": "A sturdy 300ml mug.",
"status": "active",
"vendor": "Acme",
"product_type": "Mugs",
"tags": ["kitchen", "sale"],
"tax_class": "standard",
"options": [{ "name": "Color", "values": ["White", "Black"] }],
"seo": { "title": null, "description": null },
"currency": "PLN",
"price_min": 2999,
"price_max": 3499,
"available": true,
"variants": [
{
"id": "a1b2...",
"title": "White",
"sku": "MUG-WHT",
"barcode": null,
"price": 2999,
"compare_at_price": 3999,
"cost": 1200,
"weight_grams": 350,
"stock": 12,
"available": true,
"track_inventory": true,
"allow_backorder": false,
"low_stock_threshold": 5,
"option_values": { "Color": "White" },
"position": 0,
"image_id": "c3d4..."
}
],
"images": [{ "id": "c3d4...", "url": "https://your-openshopy-domain/api/public/media/<store>/<product>/<uuid>.jpg", "alt": "", "position": 0, "variant_id": "a1b2..." }],
"featured_image": "https://your-openshopy-domain/api/public/media/<store>/<product>/<uuid>.jpg",
"collection_ids": ["d5e6..."],
"created_at": "2024-01-01T00:00:00+00:00",
"updated_at": "2024-01-02T00:00:00+00:00"
}cost is only included when the key is secret. variant.available = !track_inventory || allow_backorder || stock > 0.
GET /products/:id
Scope: products:read. :id may be a UUID or a slug. Non-secret keys can only fetch status=active products (others 404). Returns single product object as above.
POST /products
Scope: products:write, secret key required.
Body:
| Field | Type | Required | Constraints |
|---|---|---|---|
title | string | yes | 1–300 chars |
slug | string | no | max 120; auto-derived from title and de-duplicated if omitted/taken |
description | string | no | max 100000 |
status | enum | no | draft|active|archived, default draft |
vendor | string|null | no | max 200 |
product_type | string|null | no | max 200 |
tags | string[] | no | each max 60, array max 50 |
tax_class | enum | no | standard|reduced|super_reduced|zero, default standard |
options | array | no | max 3 entries, each { name: string(1-60), values: string[](max 100, each max 100) } |
seo_title | string|null | no | max 300 |
seo_description | string|null | no | max 1000 |
collection_ids | uuid[] | no | assigns product to collections (invalid ids ignored) |
variants | array | no | max 250, see variant schema below; defaults to one {price:0,title:"Default"} variant if omitted |
images | array | no | max 50, { url: string(url), alt?: string(max 500) } — images are fetched by URL server-side and re-uploaded; no base64 support here |
Variant schema, used here and for POST /products/:id/variants and PATCH /variants/:id:
| Field | Type | Required | Constraints |
|---|---|---|---|
title | string | no | 1–200 |
sku | string|null | no | max 100 |
barcode | string|null | no | max 100 |
price | int | yes | ≥0, minor units |
compare_at_price | int|null | no | ≥0 |
cost | int|null | no | ≥0 |
weight_grams | int | no | ≥0, default 0 |
stock | int | no | default 0 |
track_inventory | bool | no | default true |
allow_backorder | bool | no | default false |
low_stock_threshold | int|null | no | ≥0 |
option_values | record<string,string> | no | default {} |
position | int | no | defaults to insertion order |
Response: 201 with full product object (same shape as GET).
PATCH /products/:id
Scope: products:write, secret key required. Body is the product schema without variants/images, all fields optional (partial). slug, if provided, is re-uniqued. collection_ids, if provided, fully replaces the product's collections. Returns updated product.
DELETE /products/:id
Scope: products:write, secret key required. Deletes product, its images (storage objects removed too). Returns { "id": "...", "deleted": true }.
Variants
POST /products/:id/variants
Scope: products:write, secret required. Body: variant schema (as above). Returns 201 with the raw variant row.
PATCH /variants/:id
Scope: products:write, secret required. Body: variant schema, all fields optional. If stock is provided, it is applied as an inventory adjustment (reason api_update), so it can trigger low-stock automation. Returns updated variant row.
DELETE /variants/:id
Scope: products:write, secret required. Fails with 422 last_variant if it's the product's only variant. Returns { "id", "deleted": true }.
Images
POST /products/:id/images
Scope: products:write, secret required.
Accepts either:
multipart/form-datawith fieldsfile(binary, required),alt(string, optional),position(number, optional)- JSON body:
{ "url": "https://...", "alt"?: string(max 500), "position"?: int }— image is downloaded server-side
No base64 upload support.
Constraints: allowed content types image/jpeg, image/png, image/webp, image/gif, image/avif, image/svg+xml only (422 otherwise); max size 10 MB (422 otherwise, when sent as multipart — size isn't pre-checked for URL downloads besides whatever the fetch returns). Images are stored in the store's media storage and served publicly from https://your-openshopy-domain/api/public/media/<store_id>/<product_id>/<file>.
Optional query param variant_id (uuid) associates the uploaded image to a variant.
Response 201:
{ "data": { "id": "c3d4...", "url": "https://your-openshopy-domain/api/public/media/<store>/<product>/<uuid>.jpg", "alt": "", "position": 2 } }PATCH /products/:id/images/:imageId
Scope: products:write, secret required. Body: { alt?: string(max 500), position?: int, variant_id?: uuid|null }. Returns full image row.
POST /products/:id/images/reorder
Scope: products:write, secret required. Body: { "image_ids": uuid[] (min 1) } — sets position to each id's index in the array. Returns the refreshed product.
DELETE /products/:id/images/:imageId
Scope: products:write, secret required. Removes the storage object (if path set) and the row. Returns { "id", "deleted": true }.
Collections
GET /collections
Scope: products:read. Paginated, ordered by title. Each item includes products_count.
GET /collections/:id
Scope: products:read. :id may be uuid or slug. Returns collection plus product_ids (ordered by position).
POST /collections
Scope: products:write, secret required. Body: { title: string(1-200), slug?: string(max 120), description?: string(max 20000), image_url?: string(url)|null, product_ids?: uuid[] }. Returns 201 with collection row.
PATCH /collections/:id
Scope: products:write, secret required. Same fields, all optional. Providing product_ids fully replaces membership (with position = array index).
DELETE /collections/:id
Scope: products:write, secret required. Returns { "id", "deleted": true }.
Inventory
POST /inventory/adjust
Scope: inventory:write, secret required.
Body:
| Field | Type | Required | Notes |
|---|---|---|---|
variant_id | uuid | one of variant_id/sku required | |
sku | string | one of variant_id/sku required | looked up if variant_id absent |
delta | int | one of delta/set required | relative change |
set | int | one of delta/set required | absolute target stock |
reason | string | no | max 100, free text, default api_adjustment |
Side effect: writes an inventory movement; if the new stock crosses at or below the variant's (or store default) low_stock_threshold coming from above it, triggers the inventory.low_stock automation event.
Response:
{ "data": { "variant_id": "a1b2...", "sku": "MUG-WHT", "stock": 7 } }GET /inventory/movements
Scope: products:read, secret required. Paginated, newest first. Optional variant_id filter. Returns raw inventory_movements rows.