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
- Open Dashboard → API & Webhooks and create a key. Pick only the scopes you need; the key is shown once.
- Send requests to
https://<your-store-domain>/api/v1. The key is bound to its store, so no store header is needed. - 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.
| Scope | Grants |
|---|---|
products:read | Read products, variants, categories and brands |
products:write | Create/update/delete products, variants, categories and brands |
inventory:read | Read stock levels, low-stock and movements |
inventory:write | Adjust quantities and set thresholds |
orders:read | Read orders and their notification log |
orders:write | Status / fulfillment, notify, cancel and refund |
customers:read | Read customers and their addresses |
customers:write | Create/update customers and manage addresses |
coupons:read | Read coupons and usage |
coupons:write | Create/update/delete coupons |
shipping:read | Read shipping methods |
shipping:write | Create/update/delete shipping methods |
invoices:read | Read tax invoices |
reports:read | Read the sales summary and reports |
reports:export | Export report data |
media:read | Read the media library |
media:write | Upload, edit and delete media |
webhooks:manage | Manage 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/:idProduct with variants, images and stock |
| POST | /productsCreate a product |
| PATCH | /products/:idUpdate a product |
| DELETE | /products/:idDelete a product |
| GET | /categoriesList categories |
| POST | /categoriesCreate a category |
| PATCH | /categories/:idUpdate a category |
| DELETE | /categories/:idDelete a category |
| GET | /brandsList brands |
| POST | /brandsCreate a brand |
Inventory inventory:read / inventory:write
| GET | /inventory/stockOn-hand quantities per variant |
| GET | /inventory/low-stockItems at or below their threshold |
| GET | /inventory/movementsInventory ledger (movements) |
| POST | /inventory/adjustAdjust stock with a reason |
| PATCH | /inventory/thresholdSet the low-stock threshold |
Orders orders:read / orders:write
| GET | /orders?page=1&limit=20&status=List orders (paginated) |
| GET | /orders/:idOrder with items, totals, payment and shipping |
| GET | /orders/:id/notificationsNotification log of the order |
| PATCH | /orders/:id/statusChange status |
| PATCH | /orders/:id/fulfillmentSet carrier / tracking number |
| POST | /orders/:id/notifyRe-send a customer notification |
| POST | /orders/:id/cancelCancel an order |
| POST | /orders/:id/refundRefund an order |
Customers customers:read / customers:write
| GET | /customers?page=1&limit=20&search=List customers (paginated) |
| GET | /customers/:idCustomer with addresses and recent orders |
| POST | /customersCreate a customer |
| PATCH | /customers/:idUpdate a customer |
| GET | /customers/:id/addressesList a customer's addresses |
| POST | /customers/:id/addressesAdd an address |
| DELETE | /customers/:id/addresses/:addressIdDelete an address |
Coupons coupons:read / coupons:write
| GET | /couponsList coupons |
| GET | /coupons/:id/usageCoupon usage |
| POST | /couponsCreate a coupon |
| PATCH | /coupons/:idUpdate a coupon |
| DELETE | /coupons/:idDelete a coupon |
Shipping shipping:read / shipping:write
| GET | /shipping-methodsList shipping methods |
| POST | /shipping-methodsCreate a shipping method |
| PATCH | /shipping-methods/:idUpdate a shipping method |
| DELETE | /shipping-methods/:idDelete a shipping method |
Invoices invoices:read
| GET | /invoices?page=1&limit=20List tax invoices (paginated) |
| GET | /invoices/summaryInvoice totals over a period |
| GET | /invoices/:idGet one invoice |
Media media:read / media:write
| GET | /mediaList media library items |
| POST | /mediaUpload an image (multipart/form-data field "file") |
| DELETE | /media/:idDelete a media item |
Webhooks webhooks:manage
| GET | /integrations/webhooksList webhooks |
| POST | /integrations/webhooksCreate a webhook (returns the secret once) |
| PATCH | /integrations/webhooks/:idUpdate url / events / isActive |
| DELETE | /integrations/webhooks/:idDelete a webhook and its deliveries |
| POST | /integrations/webhooks/:id/testSend a ping event |
| GET | /integrations/webhooks/:id/deliveriesDelivery log (paginated) |
| POST | /integrations/webhooks/:id/deliveries/:deliveryId/redeliverRe-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.created | A new order was placed (any payment state). |
order.paid | Payment was confirmed for an order. |
order.status_changed | Order status changed (confirmed, shipped, delivered, cancelled...). |
product.created | A product was created from the dashboard or the API. |
customer.created | A customer account was registered. |
ping | Test 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-Event | Event name, e.g. order.paid |
X-Store-Delivery-Id | Unique delivery id (stable across retries) |
X-Store-Timestamp | Unix seconds when the request was signed |
X-Store-Signature | hex(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" }| 400 | Validation failed — message lists the invalid fields. |
| 401 | Missing, revoked or unknown API key. |
| 403 | Key lacks the required scope, or API access is disabled for the store's plan. |
| 404 | Resource not found in this store. |
| 409 | An Idempotency-Key is still in flight, or a uniqueness conflict. |
| 429 | Rate 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.