Merchant API & Webhooks

Connect your ERP, accounting, warehouse or marketplace tools to your store with a scoped REST API and signed, retried webhooks. API keys are created by the store owner under Dashboard → API & Webhooks.

Getting started

  1. Open Dashboard → API & Webhooks and create a key. Pick only the scopes you need; the key is shown once.
  2. Send requests to https://<your-store-domain>/api/v1. The key is bound to its store, so no store header is needed.
  3. Register a webhook URL for the events you care about and verify every delivery with the signing secret.
curl "https://your-store.example.com/api/v1/products?limit=5" \
  -H "Authorization: Bearer sk_live_..."

Every response is wrapped as { "success": true, "data": ..., "meta"?: {...} }. List endpoints accept page and limit (max 100) and return meta.total, meta.page, meta.totalPages. The API is versioned in the path (/api/v1).

Authentication & scopes

Send the key in either header. Live keys start with sk_live_ and sandbox keys with sk_sandbox_.

Authorization: Bearer <API_KEY>
X-API-Key: <API_KEY>

Each key is rate limited to 600 requests per minute (HTTP 429 with a Retry-After header when exceeded). A key can never manage other keys, staff, billing or settings, and a route outside its scopes returns 403.

Idempotency

Send an Idempotency-Key header (any unique string, e.g. a UUID) on POST requests. The first request runs normally and its response is stored for 24 hours; a retry with the same key replays that response (with X-Idempotent-Replay: true) instead of acting again — so a network retry never double-creates.

ScopeGrants
products:readRead products, variants, categories and brands
products:writeCreate/update/delete products, variants, categories and brands
inventory:readRead stock levels, low-stock and movements
inventory:writeAdjust quantities and set thresholds
orders:readRead orders and their notification log
orders:writeStatus / fulfillment, notify, cancel and refund
customers:readRead customers and their addresses
customers:writeCreate/update customers and manage addresses
coupons:readRead coupons and usage
coupons:writeCreate/update/delete coupons
shipping:readRead shipping methods
shipping:writeCreate/update/delete shipping methods
invoices:readRead tax invoices
reports:readRead the sales summary and reports
reports:exportExport report data
media:readRead the media library
media:writeUpload, edit and delete media
webhooks:manageManage webhooks and read the delivery log

Guides

Create a resource idempotently (Node.js)

// Idempotency-Key makes a retried POST safe — the same key returns the
// original response within 24h instead of creating a second record.
const res = await fetch("https://your-store.example.com/api/v1/coupons", {
  method: "POST",
  headers: {
    "Authorization": "Bearer " + process.env.STORE_API_KEY,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({ code: "WELCOME10", type: "PERCENTAGE", value: 10 }),
});
const { success, data } = await res.json();

Sync products page by page (Python)

import os, requests

BASE = "https://your-store.example.com/api/v1"
H = {"Authorization": f"Bearer {os.environ['STORE_API_KEY']}"}

page, items = 1, []
while True:
    r = requests.get(f"{BASE}/products", params={"page": page, "limit": 100}, headers=H)
    r.raise_for_status()
    body = r.json()
    items += body["data"]
    if page >= body["meta"]["totalPages"]:
        break
    page += 1
print(f"synced {len(items)} products")

Create a product (PHP)

<?php
$ch = curl_init("https://your-store.example.com/api/v1/products");
curl_setopt_array($ch, [
  CURLOPT_POST => true,
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => [
    "Authorization: Bearer " . getenv("STORE_API_KEY"),
    "Content-Type: application/json",
    "Idempotency-Key: " . bin2hex(random_bytes(16)),
  ],
  CURLOPT_POSTFIELDS => json_encode(["name" => "Blue T-Shirt", "price" => 79.0]),
]);
$body = json_decode(curl_exec($ch), true);

A tiny TypeScript client

type Envelope<T> = { success: boolean; data: T; meta?: { total: number; page: number; totalPages: number } };

export class StoreClient {
  constructor(private baseUrl: string, private apiKey: string) {}
  async request<T>(method: string, path: string, body?: unknown, idempotencyKey?: string): Promise<Envelope<T>> {
    const res = await fetch(this.baseUrl + path, {
      method,
      headers: {
        Authorization: `Bearer ${this.apiKey}`,
        ...(body ? { 'Content-Type': 'application/json' } : {}),
        ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
      },
      body: body ? JSON.stringify(body) : undefined,
    });
    if (res.status === 429) throw new Error('Rate limited — retry after ' + res.headers.get('Retry-After') + 's');
    return res.json();
  }
  products(page = 1) { return this.request('GET', `/products?page=${page}&limit=100`); }
}

Endpoint reference

All paths are relative to /api/v1. Bodies are JSON. Use the search box to filter, and expand Try it to call your store live with a pasted key. The machine-readable OpenAPI document is at /api/docs-json.

Products & catalog products:read / products:write

GET/products?page=1&limit=20&search=
List products (paginated)
GET/products/:id
Product with variants, images and stock
POST/products
Create a product
PATCH/products/:id
Update a product
DELETE/products/:id
Delete a product
GET/categories
List categories
POST/categories
Create a category
PATCH/categories/:id
Update a category
DELETE/categories/:id
Delete a category
GET/brands
List brands
POST/brands
Create a brand

Inventory inventory:read / inventory:write

GET/inventory/stock
On-hand quantities per variant
GET/inventory/low-stock
Items at or below their threshold
GET/inventory/movements
Inventory ledger (movements)
POST/inventory/adjust
Adjust stock with a reason
PATCH/inventory/threshold
Set the low-stock threshold

Orders orders:read / orders:write

GET/orders?page=1&limit=20&status=
List orders (paginated)
GET/orders/:id
Order with items, totals, payment and shipping
GET/orders/:id/notifications
Notification log of the order
PATCH/orders/:id/status
Change status
PATCH/orders/:id/fulfillment
Set carrier / tracking number
POST/orders/:id/notify
Re-send a customer notification
POST/orders/:id/cancel
Cancel an order
POST/orders/:id/refund
Refund an order

Customers customers:read / customers:write

GET/customers?page=1&limit=20&search=
List customers (paginated)
GET/customers/:id
Customer with addresses and recent orders
POST/customers
Create a customer
PATCH/customers/:id
Update a customer
GET/customers/:id/addresses
List a customer's addresses
POST/customers/:id/addresses
Add an address
DELETE/customers/:id/addresses/:addressId
Delete an address

Coupons coupons:read / coupons:write

GET/coupons
List coupons
GET/coupons/:id/usage
Coupon usage
POST/coupons
Create a coupon
PATCH/coupons/:id
Update a coupon
DELETE/coupons/:id
Delete a coupon

Shipping shipping:read / shipping:write

GET/shipping-methods
List shipping methods
POST/shipping-methods
Create a shipping method
PATCH/shipping-methods/:id
Update a shipping method
DELETE/shipping-methods/:id
Delete a shipping method

Invoices invoices:read

GET/invoices?page=1&limit=20
List tax invoices (paginated)
GET/invoices/summary
Invoice totals over a period
GET/invoices/:id
Get one invoice

Media media:read / media:write

GET/media
List media library items
POST/media
Upload an image (multipart/form-data field "file")
DELETE/media/:id
Delete a media item

Webhooks webhooks:manage

GET/integrations/webhooks
List webhooks
POST/integrations/webhooks
Create a webhook (returns the secret once)
PATCH/integrations/webhooks/:id
Update url / events / isActive
DELETE/integrations/webhooks/:id
Delete a webhook and its deliveries
POST/integrations/webhooks/:id/test
Send a ping event
GET/integrations/webhooks/:id/deliveries
Delivery log (paginated)
POST/integrations/webhooks/:id/deliveries/:deliveryId/redeliver
Re-send a delivery

Webhooks

Webhooks are POSTed as JSON to a public https URL (redirects are not followed, private network addresses are rejected, 10 s timeout). Respond with any 2xx status within the timeout; anything else is retried with back-off after 1 min, 5 min, 30 min, 2 h and 12 h (6 attempts in total). Deliveries are at-least-once — use id to de-duplicate.

Event catalog

order.createdA new order was placed (any payment state).
order.paidPayment was confirmed for an order.
order.status_changedOrder status changed (confirmed, shipped, delivered, cancelled...).
product.createdA product was created from the dashboard or the API.
customer.createdA customer account was registered.
pingTest event sent from the dashboard or POST /webhooks/:id/test.

Payload

{
  "id": "<delivery id — same value as X-Store-Delivery-Id>",
  "event": "order.paid",
  "sentAt": "2026-09-19T10:15:30.000Z",
  "data": { ...event-specific object, e.g. the order... }
}

Headers & signature

X-Store-EventEvent name, e.g. order.paid
X-Store-Delivery-IdUnique delivery id (stable across retries)
X-Store-TimestampUnix seconds when the request was signed
X-Store-Signaturehex(HMAC-SHA256(secret, timestamp + "." + rawBody))

Always compute the HMAC over the raw request body (before JSON parsing) and reject timestamps older than a few minutes.

Node.js

import { createHmac, timingSafeEqual } from 'node:crypto';

// Express: app.post('/webhooks/store', express.raw({ type: '*/*' }), handler)
export function handler(req, res) {
  const ts  = req.header('X-Store-Timestamp');
  const sig = req.header('X-Store-Signature');
  const expected = createHmac('sha256', process.env.STORE_WEBHOOK_SECRET)
    .update(`${ts}.${req.body.toString('utf8')}`)
    .digest('hex');
  const ok = sig && sig.length === expected.length
    && timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
    && Math.abs(Date.now() / 1000 - Number(ts)) < 300;
  if (!ok) return res.status(401).end();
  const event = JSON.parse(req.body); // { id, event, data, sentAt }
  res.status(200).end();               // ack fast, then process
}

PHP

$raw = file_get_contents('php://input');
$ts  = $_SERVER['HTTP_X_STORE_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_STORE_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $ts . '.' . $raw, getenv('STORE_WEBHOOK_SECRET'));
if (!hash_equals($expected, $sig) || abs(time() - (int) $ts) > 300) {
    http_response_code(401); exit;
}
$event = json_decode($raw, true);
http_response_code(200);

Python

import hmac, hashlib, time, os, json

def verify(headers, raw_body: bytes) -> dict | None:
    ts, sig = headers.get("X-Store-Timestamp", ""), headers.get("X-Store-Signature", "")
    msg = f"{ts}.".encode() + raw_body
    expected = hmac.new(os.environ["STORE_WEBHOOK_SECRET"].encode(), msg, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, sig) or abs(time.time() - int(ts or 0)) > 300:
        return None
    return json.loads(raw_body)

Rotating a secret from the dashboard invalidates the old one immediately — update your receiver first, then rotate.

Errors

{ "success": false, "statusCode": 403, "message": "Missing scope: orders:write" }
400Validation failed — message lists the invalid fields.
401Missing, revoked or unknown API key.
403Key lacks the required scope, or API access is disabled for the store's plan.
404Resource not found in this store.
409An Idempotency-Key is still in flight, or a uniqueness conflict.
429Rate limit exceeded — see Retry-After.

Changelog

2026-09-20

  • Scope catalog expanded to inventory, coupons, shipping, invoices, media, reports and customers:write.
  • Idempotency-Key support on all POST endpoints (replayed for 24h).
  • Customer create/update and address endpoints added.
  • Postman collection and generated reference published.

2026-08-01

  • Initial merchant API: products, orders, customers (read) and webhooks with signed, retried delivery.