Skip to main content

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_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

or 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 — the next_cursor from 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.

EventWhen
order.createdA new order was placed and paid for
order.paidPayment settled for an order
order.updatedAn order changed status
order.cancelledAn order was cancelled and its stock released
order.shippedAn order was marked shipped
order.deliveredA shipment was confirmed delivered
fulfillment.createdItems were fulfilled — carries the lines
refund.createdAn order was refunded, fully or partly
refund.failedA refund attempt failed; no money moved
product.createdA product was added
product.updatedA product was edited — carries changedFields
product.deletedA product was archived or removed
inventory.adjustedStock changed by an adjustment, stocktake or API write
product.low_stockA product fell to its low-stock threshold
product.out_of_stockA product reached zero sellable stock
customer.createdA new customer record
customer.updatedA customer record was edited
cart.abandonedA 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"? } }:

CodeHTTPMeaning
unauthorized401Missing or invalid API key
insufficient_scope403The key lacks the scope this endpoint requires
not_found404No such resource (or not on your store)
validation_failed422Invalid request
conflict409Understood, but refused given the store's current state
rate_limited429Slow down — see Retry-After
server_error500Something went wrong — contact support if it persists

Questions

Get in touch — tell us what you're building and which endpoints you need next.