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_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
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.
- 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=S256The 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.
- 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": "…" }- Call the API with it — the token goes in the same
Authorization: Bearerheader 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 signingsecretis 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— 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,
"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.
processingonly 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 frompendingorprocessing.shippedanddeliveredrecord a parcel and send the customer the same shipping or delivery email the dashboard does. Withoutitems, 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 staysprocessingwithfulfillment_state: "partial"until the last unit has shipped.origin_location_idnames 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.
| 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 |
confirmation_required | 428 | Resend with compliance_attestation: true (see Create a product) |
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.