API
API Reference
The BestWebby REST API — authenticate with a scoped key to read and write your products, orders, customers, and inventory. Base URL: https://dashboard.bestwebby.com/api/v1
Overview
BestWebby exposes a RESTful API for reading and writing your store data. You can list and fetch products, orders, customers and inventory, and you can create and update products, adjust stock, and mark orders fulfilled — enough for an ERP, a PIM or a 3PL to work against.
Writes go through the same code the dashboard itself uses. A stock adjustment made by an API key applies the same atomic, floor-clamped path as one made by a person clicking a button, and an API fulfilment hits the same refusal to ship a cancelled order.
Base URL: https://dashboard.bestwebby.com/api/v1
Authentication: a scoped API key (create one in Settings → API Keys)
Rate limit: 600 requests per minute per key. Every response includes X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset (a Unix timestamp); a 429 includes Retry-After.
Format: JSON. All money is returned as integer *_cents with a currency_code.
Authentication
Create a key in Settings → API Keys (owner-only). The full key is shown once — store it securely. Keys look like bwk_live_…. Send it as a Bearer token:
Authorization: Bearer bwk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxor via the X-API-Key header. A key is scoped to a single store and carries only the scopes you grant it; it never inherits your dashboard permissions. Available scopes:
Read: products:read · orders:read · customers:read · inventory:read
Write: products:write · inventory:write · orders:write
Grant only what an integration needs. orders:write is separate from orders:read on purpose — a reporting key that gets leaked should not be able to tell your customers their parcels have shipped.
If a key lacks the scope an endpoint requires, the request returns 403 insufficient_scope.
Pagination
List endpoints are cursor-paginated:
limit— items per page, 1–100 (default 25).cursor— thenext_cursorfrom the previous page.
{ "data": [ /* … */ ], "next_cursor": "cmqbnn5bc0005lydd98", "has_more": true }When has_more is false, next_cursor is null.
Products
List products — GET /products
Query: limit, cursor, q (name search), category, status.
{
"data": [
{
"id": "cmqblvfvh0006gr331oh8jaxw",
"name": "Classic Wool Sweater",
"sku": "WS-001-BLU-M",
"category": "Apparel",
"status": "active",
"price_cents": 4999,
"compare_at_price_cents": null,
"stock": 142,
"track_inventory": true,
"created_at": "2026-01-15T10:30:00.000Z",
"updated_at": "2026-02-02T09:00:00.000Z"
}
],
"next_cursor": null,
"has_more": false
}Create a product — POST /products
Requires products:write.
{
"name": "Blue Mug",
"category": "Homeware",
"price_cents": 1200,
"sku": "MUG-BLUE",
"cost_cents": 450,
"status": "active"
}Returns 201 with the created product. A sku that already exists returns 409 conflict with the clashing product's id — that is the single most common thing an importer gets wrong, so it has its own answer rather than a database error.
Update a product — PATCH /products/:id
Requires products:write. Partial: only the fields you send are written, so a price-sync integration cannot clobber a description it does not manage.
stock is deliberately not settable here. Writing an absolute stock number races every concurrent sale — two systems each reading 10 and each writing 8 lose a unit. Use the inventory adjustment below, which takes a signed delta.
Archive a product — DELETE /products/:id
Requires products:write. Sets status: "archived" rather than destroying the row: a product referenced by past orders cannot be deleted without taking your order history with it.
Get a product — GET /products/:id
Returns a single product (same shape) or 404 not_found.
Orders
List orders — GET /orders
Query: limit, cursor, status, email.
Get an order — GET /orders/:id
Fulfil an order — POST /orders/:id/fulfill
Requires orders:write.
{ "tracking_number": "1Z999AA10123456784", "carrier": "ups", "status": "shipped" }Every field is optional; with no status, an order with a tracking number becomes shipped and one without becomes processing. Sends the customer the same shipping email the dashboard does, and creates at most one shipment per order — calling it twice updates that shipment rather than creating a second.
Returns 409 conflict when the order cannot be fulfilled: it is cancelled or refunded, it is on a fraud hold, or it contains a product that requires serial numbers at fulfilment and they have not been assigned. Cancelling is not available here — it restocks goods and may move money, so it is not something the verb "fulfil" should be able to do.
Returns the order with its line items:
{
"data": {
"id": "cmqbo0p3h0002lydd6k2v9q4w",
"status": "paid",
"subtotal_cents": 4999,
"tax_cents": 400,
"shipping_cents": 500,
"discount_cents": 0,
"total_cents": 5899,
"currency_code": "USD",
"customer_email": "[email protected]",
"created_at": "2026-05-17T14:32:00.000Z",
"items": [
{ "product_id": "cmq…", "name": "Classic Wool Sweater", "sku": "WS-001-BLU-M", "variant_name": "Blue / M", "quantity": 1, "unit_price_cents": 4999, "total_cents": 4999 }
]
}
}Customers
GET /customers (query: limit, cursor, q) and GET /customers/:id return id, email, name, phone, created_at.
Inventory
Adjust stock — POST /inventory
Requires inventory:write.
{ "product_id": "cmq…", "delta": -3, "reason": "Damaged in transit" }A signed delta, never an absolute quantity — a delta composes correctly when two systems adjust at once, and an absolute value silently loses one of them.
Stock is floored at zero. If you ask to remove 10 from a stock of 3, three are removed and the response says so:
{
"data": {
"product_id": "cmq…",
"previous_stock": 3,
"stock": 0,
"requested_delta": -10,
"applied_delta": -3,
"clamped": true
}
}Check clamped if you are reconciling counts against another system.
List stock levels — GET /inventory
Query: limit, cursor, product_id, location_id. Returns per-location levels including available-to-sell (available). Stock is decremented the moment a sale commits, so available equals the on-hand quantity:
{
"data": [
{ "product_id": "cmq…", "variant_id": null, "location_id": "cmqbnz1aa0001lydd8h3x7f2m", "quantity": 142, "available": 142 }
],
"next_cursor": null,
"has_more": false
}Webhooks
Register endpoints in Settings → Webhooks. BestWebby POSTs a signed JSON body when an event fires and retries with exponential backoff.
Body: { "event": "order.created", "data": { … }, "timestamp": 1717000000000 } — timestamp is Unix milliseconds, not seconds. Keep that in mind if you validate deliveries against a replay window.
Verifying a delivery
The X-BestWebby-Signature header is sha256= followed by the HMAC-SHA256 of the raw request body, keyed with your endpoint's signing secret (shown once, prefixed whsec_). Verify against the raw bytes — do not parse and re-serialize, because key order and whitespace would change and the signature would never match.
import crypto from 'node:crypto'
function verify(rawBody, header, secret) {
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex')
const a = Buffer.from(expected)
const b = Buffer.from(header ?? '')
return a.length === b.length && crypto.timingSafeEqual(a, b)
}Other headers on every delivery: X-BestWebby-Event (the event name), X-BestWebby-Delivery (a unique id for this attempt — use it to make your handler idempotent) and X-BestWebby-Timestamp.
Rotating a secret
Rotating by simply replacing a secret breaks every delivery until you have shipped the new value, which is why most people never rotate. Here it does not.
When you start a rotation, X-BestWebby-Signature keeps being signed with your current secret, and the new one arrives alongside in X-BestWebby-Signature-Next. Accept either while you deploy, then click Finish rotation — at that point the new secret becomes the only one. The overlap lasts seven days, after which the second header stops being sent.
If your endpoint stops responding
Deliveries that fail are retried. After 20 consecutive failures the endpoint is turned off automatically and the reason is shown in Settings → Webhooks — a dead endpoint retried forever is a flood against whoever now owns that address. Re-enable it once you have fixed the receiver; the failure count resets.
You can replay any past delivery from the delivery log. A replay is recorded as a new attempt that references the original, so the history of what was actually sent stays intact.
Events
Every event below is emitted by the platform. The subscription picker offers exactly this list and refuses anything else, so a subscription that never delivers is a real problem rather than an event that was never wired up.
| Event | When |
|---|---|
order.created | A new order was placed and paid for |
order.paid | Payment settled for an order |
order.updated | An order changed status |
order.cancelled | An order was cancelled and its stock released |
order.shipped | An order was marked shipped |
order.delivered | A shipment was confirmed delivered |
fulfillment.created | Items were fulfilled — carries the lines |
refund.created | An order was refunded, fully or partly |
refund.failed | A refund attempt failed; no money moved |
product.created | A product was added |
product.updated | A product was edited — carries changedFields |
product.deleted | A product was archived or removed |
inventory.adjusted | Stock changed by an adjustment, stocktake or API write |
product.low_stock | A product fell to its low-stock threshold |
product.out_of_stock | A product reached zero sellable stock |
customer.created | A new customer record |
customer.updated | A customer record was edited |
cart.abandoned | A checkout was left unpaid past the abandonment window |
Threshold events fire on the crossing, not on every subsequent change — a product sitting at low stock does not re-announce itself on each adjustment.
Error format
Errors return { "error": { "code", "message", "details"? } }:
| Code | HTTP | Meaning |
|---|---|---|
unauthorized | 401 | Missing or invalid API key |
insufficient_scope | 403 | The key lacks the scope this endpoint requires |
not_found | 404 | No such resource (or not on your store) |
validation_failed | 422 | Invalid request |
conflict | 409 | Understood, but refused given the store's current state |
rate_limited | 429 | Slow down — see Retry-After |
server_error | 500 | Something went wrong — contact support if it persists |
Questions
Get in touch — tell us what you're building and which endpoints you need next.