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.

API keys and webhooks are part of every plan.

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 (owners and admins). Under What this key can do, tick what the integration needs: a new key starts with the four read scopes ticked, and can only read until you tick more. The full key is shown once — store it securely. If you use two-factor authentication, creating a key asks for a fresh 6-digit code from your authenticator app; one code covers five minutes. 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

Webhooks: webhooks:read · webhooks:write

The keys list shows each key's scopes under Can. To change what a key can do, create a new key with the scopes you want and delete the old one.

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.

Apps: authenticate as an installed integration

If you are building an integration that other people install on their store, do not ask them for an API key. Register an app (Settings → Apps), send the merchant to an approval screen, and exchange the code you get back for a token scoped to what they approved. Registering, changing or removing an app asks for your two-factor code the same way as a key.

  1. Send the merchant to the approval screen
https://dashboard.bestwebby.com/oauth/authorize
  ?client_id=bwa_…
  &redirect_uri=https://your-app.example.com/callback
  &scope=orders:read%20orders:write
  &state=<random, checked on return>
  &code_challenge=<base64url(sha256(verifier))>   # optional; required if your app has no secret
  &code_challenge_method=S256

The redirect_uri must match one you registered exactly — no prefix matching, no fragments. An unregistered URI is refused outright and nothing is sent to it.

  1. Exchange the code for a token (server-side; the code is single-use and expires in five minutes)
curl -X POST https://dashboard.bestwebby.com/api/oauth/token \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "authorization_code",
    "code": "…",
    "client_id": "bwa_…",
    "client_secret": "bws_…",
    "redirect_uri": "https://your-app.example.com/callback"
  }'
{ "access_token": "bwi_live_…", "token_type": "Bearer", "scope": "orders:read orders:write", "install_id": "…" }
  1. Call the API with it — the token goes in the same Authorization: Bearer header as a key, and every endpoint behaves identically. It carries only the scopes the merchant approved, and stops working the moment they remove the app.

Merchants can remove an app at any time from Settings → Apps; removal is immediate, and any webhook endpoints your app created are disabled with it.

Webhooks

Rather than polling, subscribe to events. Requires webhooks:read / webhooks:write.

  • GET /webhooks — your subscriptions. An installed app sees only its own.
  • POST /webhooks — { "url": "https://…", "events": ["order.paid"] }. The signing secret is returned once, on creation. The URL must be public and https.
  • DELETE /webhooks?id=… — remove one.

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,
      "tax_category": null,
      "hs_code": "691200",
      "origin_country": "CA",
      "cusma_qualifying": false,
      "origin_claim": null,
      "origin_qualifier": null,
      "origin_attested_at": null,
      "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,
  "tax_category": "groceries",
  "hs_code": "6912.00",
  "origin_country": "CA",
  "status": "active",
  "compliance_attestation": true
}

Tax category. tax_category says what the product is, for tax, exactly as the Tax category field on the product page does. It is one of groceries, prepared_food, childrens_clothing, books, prescription_drugs, medical_devices, digital_services, professional_services or exempt; null (or leaving it out) is general goods, taxed at the full rate. The category's label ("Basic groceries") is read as its key, and "" or "general" as general goods. Any other value is refused with 422 validation_failed naming the field, and nothing is written. Every product you read back carries it.

Customs. hs_code, origin_country and cusma_qualifying are what a label to another country declares for the product, the same fields as Compliance & customs on the product page. hs_code is 6 to 10 digits (dots, spaces and dashes are ignored, and it is stored as digits only); origin_country is a two-letter ISO 3166-1 code (stored upper-case); cusma_qualifying is true only if you can certify the product originates under CUSMA (USMCA). null clears an HS code or country. Any other value is refused with 422 validation_failed naming the field, and nothing is written. A change is recorded in the audit log with its old and new value.

Origin claim. origin_claim is your own "Product of Canada" or "Made in Canada" claim: product_of_canada, made_in_canada, or null (or "") for none. made_in_canada needs origin_qualifier, the words that follow it on the product page (for example "with imported parts", at most 80 characters); with any other claim, origin_qualifier must be left out or null. A new or changed claim is written only with "origin_claim_attestation": true, which confirms the product meets the claim's test (see Product of Canada and Made in Canada); it is stamped with your API key and the time, returned as origin_attested_at. Sending the claim a product already has asks for nothing, and null takes a claim off without one. A claim on a product whose tax_category is groceries or prepared_food is refused: origin claims on food follow CFIA rules. A refusal is 422 validation_failed naming the field, and nothing is written. A change is recorded in the audit log with its old and new value.

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.

Listing confirmation. Creating a product that is not a draft, publishing one, or changing what shoppers read on a live product (its name, description, category or tags) needs "compliance_attestation": true in the body. It records that you confirm you have the right to sell the product, that it is lawful in every country you sell to, that you hold any licence those countries require, and that the description and images are accurate. Without it the request is refused with 428 confirmation_required and nothing is written. A draft or archived product, and a change to price or stock alone, never need it.

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. tax_category is checked as it is on create; send null to make a product general goods again. The customs fields are checked as on create too, and so is the origin claim: one resent unchanged (a read, then a write back) needs no attestation, and a tax_category of groceries or prepared_food is refused while the product carries a claim.

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, number.

number finds an order by the number your store's screens, receipts and emails show, such as #AB12CD34 (send the # as %23, or leave it out), or by any end of its id. Every order whose id ends that way is listed, so if two orders share an ending you get both. A value that cannot be an order number answers 422 validation_failed.

Each order includes number, the same #AB12CD34 form.

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.

  • processing only acknowledges the order (you have it, it is not packed yet). Nothing is recorded as shipped, no shipment is created and the customer is not emailed. It is accepted only from pending or processing.
  • shipped and delivered record a parcel and send the customer the same shipping or delivery email the dashboard does. Without items, the parcel holds everything still outstanding, and calling it again updates that same shipment rather than creating a second.
  • With items ([{ "order_item_id": "…", "quantity": 1 }]) you ship part of the order. Each call is its own parcel with its own tracking number and email; the order stays processing with fulfillment_state: "partial" until the last unit has shipped. origin_location_id names the location the parcel leaves from.

Returns 409 conflict when the order cannot be fulfilled: it has not been paid yet (pending_payment, payment_failed), it is cancelled or refunded, it is on a fraud hold, it contains a product that requires serial numbers at fulfilment and they have not been assigned, or the items named have already shipped. 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
confirmation_required428Resend with compliance_attestation: true (see Create a product)
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.