OpenShopy
Dokumentacja API

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:

json
{
  "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:

ParamTypeDescription
page, limitintpagination
statusdraft|active|archivedSecret keys only; publishable keys always see only active products regardless of this param
qstringsearches title and slug (ILIKE)
tagstringproducts whose tags array contains this value
product_typestringexact match
collection_iduuidproducts belonging to this collection
idscomma-separated uuidsfilter to specific product ids (invalid uuids silently dropped)
sortstringcreated_at|updated_at|title, prefix with - for descending; default -created_at; any other value falls back to created_at
currencyPLN|USD|EURconvert displayed prices; defaults to store default currency

Response: { "data": [Product...], "meta": {page,limit,total,total_pages} }

Product object:

json
{
  "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:

FieldTypeRequiredConstraints
titlestringyes1–300 chars
slugstringnomax 120; auto-derived from title and de-duplicated if omitted/taken
descriptionstringnomax 100000
statusenumnodraft|active|archived, default draft
vendorstring|nullnomax 200
product_typestring|nullnomax 200
tagsstring[]noeach max 60, array max 50
tax_classenumnostandard|reduced|super_reduced|zero, default standard
optionsarraynomax 3 entries, each { name: string(1-60), values: string[](max 100, each max 100) }
seo_titlestring|nullnomax 300
seo_descriptionstring|nullnomax 1000
collection_idsuuid[]noassigns product to collections (invalid ids ignored)
variantsarraynomax 250, see variant schema below; defaults to one {price:0,title:"Default"} variant if omitted
imagesarraynomax 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:

FieldTypeRequiredConstraints
titlestringno1–200
skustring|nullnomax 100
barcodestring|nullnomax 100
priceintyes≥0, minor units
compare_at_priceint|nullno≥0
costint|nullno≥0
weight_gramsintno≥0, default 0
stockintnodefault 0
track_inventoryboolnodefault true
allow_backorderboolnodefault false
low_stock_thresholdint|nullno≥0
option_valuesrecord<string,string>nodefault {}
positionintnodefaults 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-data with fields file (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:

json
{ "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:

FieldTypeRequiredNotes
variant_iduuidone of variant_id/sku required
skustringone of variant_id/sku requiredlooked up if variant_id absent
deltaintone of delta/set requiredrelative change
setintone of delta/set requiredabsolute target stock
reasonstringnomax 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:

json
{ "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.