Developers

API referencev1

The SitesPlaced API lets your code work with one store: read its products, send a shopper to its checkout, read and update its orders, look up tracking and receive webhooks. New here? Start with the three-step quick start.

Updated 18 September 2026

Base URL

https://sitesplaced.com/api/v1

Always use this host exactly as written, never www. Every path on this page is relative to it, so GET /me means GET https://sitesplaced.com/api/v1/me.

Authentication

An API key is a password for a program. The store's owner creates one in the dashboard under Settings → Developer. Send it in a header with every request:

Authorization: Bearer sp_live_sk_…

X-Api-Key: … is also accepted. A key in the URL is refused. Web addresses end up in browser history and server logs, which is no place for a password.

The kinds of key

KeyLooks likeWhere it may liveWhat it can do
Secretsp_live_sk_ + 48 hex charactersServers onlyWhatever permissions it was given
Publishablesp_live_pk_ + 48 hex charactersBrowser codeproducts:read, checkout:write and tracking:read only, and only from the websites on its allowed list
Legacy (Zapier)sp_live_ + 40 hex charactersServers onlyorders:read and webhooks:write
  • A secret key sent from a web browser is refused with a 403. The API sees a browser when the request carries an Origin header together with Sec-Fetch-Mode.
  • A publishable key sent with an Origin that is not on its allowed list is refused with a 403.
  • A connection keyis the key a plugin receives when a shop connects. It is a secret key bound to one connected store. Routes marked “connection key” accept only such a key.
  • The legacy key is the older single store key that Zapier uses. It keeps working as before, and on this API it can read orders and manage webhooks.

Permissions

A permission says what a key may do. Each route names the one it needs. A key without it gets a 403. Give a key only the permissions its program needs.

PermissionWhat it allowsTicked by default on a new secret keyA publishable key may hold itA plugin's connection key has it
orders:readRead ordersYesNoYes
orders:writeCreate and update ordersYesNoYes
products:readRead productsYesYesNo
products:writeSend products from another platformNoNoYes
checkout:writePrepare a checkout for a shopperYesYesNo
tracking:readLook up order trackingYesYesYes
webhooks:writeManage webhooksNoNoNo
connection:manageManage a connected storeNoNoYes

Conventions

  • JSON in, JSON out, in UTF-8. A request body can be up to 1 MB.
  • Dates are ISO 8601 in UTC, such as 2026-09-18T10:00:00.000Z.
  • Money is a plain number in the order's currency, such as 1499.5. It is not in paise or cents.
  • Every response carries X-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset (unix seconds) and Cache-Control: no-store.

Rate limits

KeyFree planPaid store plan
Secret key60 requests a minute per key300 requests a minute per key
Publishable key600 a minute per key, and 30 a minute per visitor IP3,000 a minute per key, and 60 a minute per visitor IP

Over the limit you get a 429 with a Retry-After header that gives the number of seconds to wait. A paid store plan means Growth or AI Co-founder; see pricing.

Errors

Errors always look like this:

{
  "error": {
    "code": "invalid_request",
    "message": "Plain words.",
    "param": "totals.total",
    "request_id": "req_…"
  }
}

code is one of a fixed list, so your program can branch on it. message is written in plain words for a person. param appears when one field is at fault.

HTTPcodeWhen
400invalid_requestThe request is malformed or a field has a bad value. param names the field.
401unauthorizedThe key is missing, wrong or revoked.
403forbiddenThe key is real but may not do this.
404not_foundNo such order, product or webhook in this store.
409conflictThe request clashes with the current state, for example cancelling another platform's order.
413payload_too_largeThe body is over 1 MB.
422unprocessableThe request is well formed but cannot be carried out, for example an unknown product in a cart.
429rate_limitedToo many requests. Wait for Retry-After seconds.
500internal_errorSomething went wrong on our side.
503unavailableTry again shortly.

Paging

Lists are paged with a cursor. Send ?limit= (1 to 100, default 25) and ?cursor=. Lists come newest first.

{
  "data": [ … ],
  "has_more": true,
  "next_cursor": "…"
}

While has_more is true, ask again with cursor set to the next_cursor you were given. Treat the cursor as an opaque string: do not build one yourself.

Request id

Every response has an X-Request-Id header, and every error repeats it as error.request_id. Log it. When you ask us for help, that id finds the exact call.

The order object

{
  "id": "0b8c…uuid",
  "object": "order",
  "order_number": "1234",
  "source": "woocommerce",
  "external_id": "1234",
  "external_number": "1234",
  "external_status": "processing",
  "external_url": "https://shop.example.com/wp-admin/post.php?post=1234&action=edit",
  "status": "confirmed",
  "payment": {
    "method": "manual",
    "status": "paid",
    "external_method": "razorpay",
    "external_method_title": "Credit Card/UPI"
  },
  "totals": { "total": 1499, "discount": 100, "shipping": 99, "cod_fee": 0 },
  "currency": "INR",
  "customer": {
    "name": "Asha Rao",
    "email": "asha@example.com",
    "phone": "+919811216363",
    "address": "12 MG Road, Indiranagar",
    "city": "Bengaluru",
    "state": "Karnataka",
    "postcode": "560038",
    "country": "IN"
  },
  "items": [
    {
      "name": "Silver hoop earrings",
      "variant": "Size: Small",
      "quantity": 1,
      "unit_price": 1400,
      "sku": "HOOP-S",
      "product_id": null,
      "external_product_id": "88",
      "image_url": "https://…"
    }
  ],
  "tracking": {
    "number": "AWB123",
    "courier": "Delhivery",
    "url": "https://…",
    "status_text": "In transit",
    "page_url": "https://shop-orders.sitesplaced.site/track-order?order=1234"
  },
  "note": "Please gift wrap",
  "risk": null,
  "archived": false,
  "created_at": "2026-09-18T10:00:00.000Z",
  "updated_at": "2026-09-18T10:05:00.000Z"
}
FieldValues
sourcenative (the SitesPlaced checkout), woocommerce, shopify or api
statuspending, confirmed, shipped, out_for_delivery, completed (this means delivered) or cancelled
payment.statusunpaid, pending, paid, failed or refunded
payment.methodFor an order copied in from another platform this is only ever cod or manual. The real method is in payment.external_method and payment.external_method_title.
external_*The order's id, number, status and admin address on the other platform. Null for an order placed on the SitesPlaced checkout.
tracking.page_urlThe public tracking page for this order.
risknull, or { "level": "medium", "flags": [ { "code": "bad_phone", "label": "…" } ] }. The level is medium or high. Only cash-on-delivery orders are ever flagged.

Sending an order in

POST /orders needs the orders:write permission. It creates the order, or updates it if the same external_id was sent before. That makes it safe to retry: if you are not sure a request got through, send it again and you will not get a second order.

The smallest request that works:

Request
curl -X POST https://sitesplaced.com/api/v1/orders \
  -H "Authorization: Bearer sp_live_sk_…" \
  -H "Content-Type: application/json" \
  -d '{ "external_id": "1234", "totals": { "total": 1499 } }'

The full input shape:

Body
{
  "external_id": "1234",
  "number": "1234",
  "status": "processing",
  "status_kind": "woocommerce",
  "payment": {
    "status": "paid",
    "method": "razorpay",
    "method_title": "Credit Card/UPI",
    "is_cod": false,
    "transaction_id": "pay_Abc",
    "refunded_amount": 0
  },
  "currency": "INR",
  "totals": { "total": 1499, "discount": 100, "shipping": 99, "cod_fee": 0, "tax": 0 },
  "customer": {
    "name": "Asha Rao",
    "email": "asha@example.com",
    "phone": "+919811216363",
    "address_line1": "12 MG Road",
    "address_line2": "Indiranagar",
    "city": "Bengaluru",
    "state": "Karnataka",
    "postcode": "560038",
    "country": "IN"
  },
  "items": [
    {
      "external_product_id": "88",
      "name": "Silver hoop earrings",
      "sku": "HOOP-S",
      "variant": "Size: Small",
      "quantity": 1,
      "unit_price": 1400,
      "image_url": "https://…"
    }
  ],
  "note": "Please gift wrap",
  "tracking": { "number": null, "courier": null, "url": null },
  "created_at": "2026-09-18T10:00:00Z",
  "updated_at": "2026-09-18T10:05:00Z",
  "admin_url": "https://shop.example.com/wp-admin/post.php?post=1234&action=edit",
  "deleted": false,
  "test": false,
  "delivered": false
}

The rules

  • Required: external_id, and totals.total as a number of 0 or more. Everything else is optional.
  • status_kind says how to read status. woocommerce: WooCommerce status slugs, with or without the wc- prefix. shopify: open, partial, fulfilled or cancelled. sitesplaced: the six statuses above, plus the aliases delivered, fulfilled, processing, paid and canceled. A connection key defaults to its own platform. Any other key defaults to sitesplaced.
  • payment.status is unpaid, pending, paid, failed, refunded or partially_refunded. Send it. If it is missing, the server guesses from the order status.
  • payment.is_cod is true for cash on delivery. This is what turns on the cash-on-delivery risk flags.
  • Not orders: the WooCommerce statuses checkout-draft, auto-draft, draft and trash are skipped, and so is anything sent with test: true. The response is action: "skipped".
  • deleted: true archives the copy. An order is never hard-deleted.
  • Stale updates are ignored. If updated_at is older than the copy SitesPlaced holds, nothing changes and the response is action: "unchanged". Updates that arrive out of order cannot undo newer ones.
  • Text is cleaned. It is trimmed, HTML tags are stripped, and up to 200 line items are kept.
  • The key decides the source, never the body. A WooCommerce connection key gives source: "woocommerce", a Shopify connection gives shopify, and any other secret key gives api.

The response is 201 when the order was created and 200 otherwise:

Response 201
{
  "order": { …order object… },
  "action": "created"
}

action is created, updated, unchanged or skipped. When it is skipped, order is null and reason says why:

Response 200 (skipped)
{
  "order": null,
  "action": "skipped",
  "reason": "…"
}

Batches

POST /orders/batch takes up to 50 orders in one call. Each one follows the same rules as a single order.

Body
{
  "orders": [ …up to 50 orders, each in the input shape… ],
  "backfill": true
}

backfill: truemeans “this is history”. No mobile push, no WhatsApp message and no webhook is sent for those orders. Use it when you import a shop's past orders.

Response 200
{
  "results": [
    { "external_id": "1234", "ok": true, "action": "created", "id": "uuid" },
    { "external_id": "1235", "ok": false, "error": "…" }
  ]
}

One bad order never fails the batch. Read each result on its own.

Routes

Method and pathKeyPermission
GET /meany
GET /orderssecretorders:read
POST /orderssecretorders:write
POST /orders/batchsecretorders:write
GET /orders/{id}secretorders:read
PATCH /orders/{id}secretorders:write
DELETE /orders/{id}secretorders:write
POST /external-products/batchconnection keyproducts:write
DELETE /external-products/{external_id}connection keyproducts:write
GET /productssecret or publishableproducts:read
GET /products/{id}secret or publishableproducts:read
GET /categoriessecret or publishableproducts:read
POST /checkout/sessionssecret or publishablecheckout:write
GET /trackingsecret or publishabletracking:read
GET /webhookssecretwebhooks:write
POST /webhookssecretwebhooks:write
DELETE /webhooks/{id}secretwebhooks:write
POST /connect/exchangenone (one-time code)
POST /connect/disconnectconnection keyconnection:manage
GET /connectionconnection keyconnection:manage
PUT /connectionconnection keyconnection:manage
GET /connection/updatesconnection keyconnection:manage
POST /connection/updates/ackconnection keyconnection:manage
GET /openapi.jsonnone

GET/me

Key: any valid key · No permission needed

Tells you which store a key belongs to, what the key may do and its rate limit. Make this your first call.

Request
curl https://sitesplaced.com/api/v1/me \
  -H "Authorization: Bearer sp_live_sk_…"
Response 200
{
  "api_version": "v1",
  "store": {
    "id": "uuid",
    "name": "Asha Jewels",
    "slug": "asha-jewels",
    "url": "https://asha-jewels.sitesplaced.site",
    "tracking_url": "https://asha-jewels.sitesplaced.site/track-order",
    "currency": "INR",
    "mode": "full"
  },
  "key": { "kind": "secret", "scopes": ["orders:read"], "connection_id": null },
  "limits": { "requests_per_minute": 60 }
}

store.mode is full for an ordinary SitesPlaced store, or tracking_only for a connected store whose public address shows only the tracking page. key.connection_idis set when the key is a plugin's connection key.

GET/orders

Key: secret · Permission: orders:read

Lists the store's orders, newest first, in the list envelope.

QueryMeaning
limit1 to 100. Default 25.
cursorThe next_cursor from the page before.
statusOne of the six order statuses.
sourcenative, woocommerce, shopify or api.
external_idThe order's id on the other platform.
updated_sinceAn ISO 8601 date. Only orders updated since then.
created_sinceAn ISO 8601 date. Only orders created since then.
include_archived=trueAlso return archived orders. They are left out by default.
Request
curl "https://sitesplaced.com/api/v1/orders?status=confirmed&updated_since=2026-09-18T00:00:00Z&limit=50" \
  -H "Authorization: Bearer sp_live_sk_…"
Response 200
{
  "data": [ { …order object… }, { …order object… } ],
  "has_more": true,
  "next_cursor": "…"
}

POST/orders

Key: secret · Permission: orders:write

Creates an order or updates the one with the same external_id. The body, the rules and the response are described under Sending an order in.

Errors: 400 invalid_request when external_id or totals.total is missing or bad, with param naming the field. 413 payload_too_large when the body is over 1 MB.

POST/orders/batch

Key: secret · Permission: orders:write

Up to 50 orders in one call. See Batches.

GET/orders/{id}

Key: secret · Permission: orders:read

Returns one order. id is the SitesPlaced order id (a uuid), not the order number. To find an order by its id on the other platform, use GET /orders?external_id=.

Request
curl https://sitesplaced.com/api/v1/orders/0b8c…uuid \
  -H "Authorization: Bearer sp_live_sk_…"
Response 200
{ "order": { …order object… } }

An id that is not in this store gives 404 not_found.

PATCH/orders/{id}

Key: secret · Permission: orders:write

Changes the status, the tracking details, or both. Send any of the three fields: status, tracking and comment.

Request
curl -X PATCH https://sitesplaced.com/api/v1/orders/0b8c…uuid \
  -H "Authorization: Bearer sp_live_sk_…" \
  -H "Content-Type: application/json" \
  -d '{
    "status": "shipped",
    "tracking": { "number": "AWB123", "courier": "Delhivery", "url": "https://…" },
    "comment": "Dispatched"
  }'
Response 200
{ "order": { …order object… } }

DELETE/orders/{id}

Key: secret · Permission: orders:write

Archives the order. It is not deleted: it drops out of GET /orders unless you ask with include_archived=true.

Request
curl -X DELETE https://sitesplaced.com/api/v1/orders/0b8c…uuid \
  -H "Authorization: Bearer sp_live_sk_…"
Response 200
{ "id": "0b8c…uuid", "archived": true }

POST/external-products/batch

Key: connection key · Permission: products:write

Used by the WooCommerce plugin and the Shopify connector to send a connected shop's product list. These products are kept apart from the store's own products: they can never appear on a SitesPlaced storefront or be put in a SitesPlaced cart. Only a connection key is accepted. Send up to 100 products per call; external_id and name are required on each.

Body
{
  "products": [
    {
      "external_id": "88",
      "name": "Silver hoop earrings",
      "sku": "HOOP-S",
      "price": 1400,
      "currency": "INR",
      "image_url": "https://…",
      "category": "Earrings",
      "stock": 12
    }
  ]
}
Response 200
{
  "results": [
    { "external_id": "88", "ok": true },
    { "external_id": "89", "ok": false, "error": "…" }
  ]
}

DELETE/external-products/{external_id}

Key: connection key · Permission: products:write

Removes one copied product, by its id on the other platform. Call it when the product is deleted there.

Response 200
{ "external_id": "88", "deleted": true }

GET/products

Key: secret or publishable · Permission: products:read

Lists the store's own products, for a headless site. The store must be published. Prices are the ones the storefront shows. Query: limit, cursor and category.

Request
curl "https://sitesplaced.com/api/v1/products?category=Earrings&limit=25" \
  -H "Authorization: Bearer sp_live_pk_…"
Response 200
{
  "data": [ { …product object… } ],
  "has_more": false,
  "next_cursor": null
}

The product object:

{
  "id": "uuid",
  "object": "product",
  "name": "…",
  "description": "…",
  "price": 499,
  "compare_at_price": 699,
  "currency": "INR",
  "image_url": "https://…",
  "images": ["https://…"],
  "category": "Earrings",
  "categories": ["Earrings"],
  "type": "physical",
  "in_stock": true,
  "stock": 12,
  "variants": [
    { "options": { "Size": "M" }, "price": 499, "stock": 4, "sku": "SKU-M" }
  ],
  "url": "https://asha-jewels.sitesplaced.site/products/uuid"
}

GET/products/{id}

Key: secret or publishable · Permission: products:read

Returns one product by its id.

Response 200
{ "product": { …product object… } }

GET/categories

Key: secret or publishable · Permission: products:read

Returns the store's category names. Pass one to GET /products?category=.

Response 200
{ "data": ["Earrings", "Necklaces"] }

POST/checkout/sessions

Key: secret or publishable · Permission: checkout:write

Prepares a cart and returns a link to it. Send the shopper to url. The SitesPlaced cart opens with those lines already in it, and the shopper pays there.

Request
curl -X POST https://sitesplaced.com/api/v1/checkout/sessions \
  -H "Authorization: Bearer sp_live_pk_…" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [
      { "product_id": "uuid", "quantity": 2, "variant": { "Size": "M" } }
    ],
    "return_url": "https://myshop.com/thanks"
  }'
Response 201
{
  "url": "https://asha-jewels.sitesplaced.site/cart?restore=cl_…",
  "token": "cl_…",
  "expires_at": "…"
}
  • Prices, stock, payment methods and every checkout guard are SitesPlaced's own. You send product ids and quantities, never prices.
  • A cart can hold up to 50 lines.
  • An unknown or archived product gives 422 unprocessable.
  • With a publishable key, return_urlmust be on one of the key's allowed websites. This stops anyone who copies the key from using your checkout to send shoppers to a site of their choosing.
  • The link works for 24 hours.

The whole flow is under The headless checkout.

GET/tracking

Key: secret or publishable · Permission: tracking:read

Looks up one order's status for a shopper. Query: order (the order number) and, for an order copied in from another platform, contact.

Request
curl "https://sitesplaced.com/api/v1/tracking?order=1234&contact=6363" \
  -H "Authorization: Bearer sp_live_pk_…"
Response 200
{
  "order": {
    "order_number": "1234",
    "status": "shipped",
    "items": [ … ],
    "total_amount": 1499,
    "shipping_fee": 99,
    "cod_fee": 0,
    "currency": "INR",
    "tracking_link": "https://…",
    "status_comment": "Dispatched",
    "shipping_awb": "AWB123",
    "shipping_courier": "Delhivery",
    "shipping_status_text": "In transit",
    "status_history": [ … ],
    "created_at": "2026-09-18T10:00:00.000Z",
    "updated_at": "2026-09-18T12:00:00.000Z"
  },
  "store": { "name": "Asha Jewels" }
}
  • The response never contains a name, a phone number, an email or an address.
  • An unknown order gives 404 not_found.
  • An order copied in from another platform needs contact: the last 4 digits of the phone number on the order, or the email. If it is missing you get 422 with param: "contact". If it is wrong you get 422. Five wrong tries give 429 for 15 minutes.
Response 422 (contact is missing)
{
  "error": {
    "code": "unprocessable",
    "message": "…",
    "param": "contact",
    "request_id": "req_…"
  }
}

Why the second detail exists is explained under Tracking and the second detail.

GET/webhooks

Key: secret · Permission: webhooks:write

Lists the store's webhook endpoints. The signing secret is never listed.

Response 200
{
  "data": [
    {
      "id": "uuid",
      "url": "https://myshop.com/hooks/sitesplaced",
      "events": ["order.created", "order.shipped"],
      "active": true,
      "created_at": "2026-09-18T10:00:00.000Z"
    }
  ]
}

POST/webhooks

Key: secret · Permission: webhooks:write

Registers an address to receive events. url must be a public https address. The secret in the response is shown once: store it now, because you need it to check signatures.

Request
curl -X POST https://sitesplaced.com/api/v1/webhooks \
  -H "Authorization: Bearer sp_live_sk_…" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://myshop.com/hooks/sitesplaced", "events": ["order.created", "order.shipped"] }'
Response 201
{
  "id": "uuid",
  "url": "https://myshop.com/hooks/sitesplaced",
  "events": ["order.created", "order.shipped"],
  "secret": "whsec_…"
}

The events and the delivery rules are under Webhooks.

DELETE/webhooks/{id}

Key: secret · Permission: webhooks:write

Removes a webhook endpoint. No more events are sent to it.

Response 200
{ "id": "uuid", "deleted": true }

POST/connect/exchange

Key: none (a one-time code) · No permission needed

The last step of connecting a plugin: the plugin swaps the one-time code for its own key, server to server. No key is sent, because the plugin does not have one yet. The code works once, for 10 minutes, and only when the host of site_url matches the shop that was approved. A failure gives 400.

Request
POST https://sitesplaced.com/api/v1/connect/exchange

{ "code": "spc_…", "site_url": "https://shop.example.com" }
Response 200
{
  "api_key": "sp_live_sk_…",
  "webhook_secret": "whsec_…",
  "connection_id": "uuid",
  "store": {
    "id": "uuid",
    "name": "…",
    "slug": "…",
    "tracking_url": "https://…/track-order",
    "dashboard_url": "https://sitesplaced.com/sites/uuid/commerce/orders"
  }
}

The whole flow is under Connecting a plugin.

POST/connect/disconnect

Key: connection key · Permission: connection:manage

Closes the connection. The key stops working immediately afterwards. The orders already copied are kept.

Response 200
{ "ok": true }

GET/connection

Key: connection key · Permission: connection:manage

The status of the connection this key belongs to. A plugin shows this on its own status screen.

Response 200
{
  "connection": {
    "id": "uuid",
    "platform": "woocommerce",
    "status": "…",
    "status_detail": "…",
    "shop_url": "https://shop.example.com",
    "orders_synced": 128,
    "last_order_at": "2026-09-18T10:00:00.000Z"
  },
  "store": {
    "id": "uuid",
    "name": "…",
    "slug": "…",
    "tracking_url": "https://…/track-order",
    "dashboard_url": "https://sitesplaced.com/sites/uuid/commerce/orders"
  }
}

PUT/connection

Key: connection key · Permission: connection:manage

The plugin reports its version and the address SitesPlaced should send updates to. push_url may be nullwhen the shop's REST API cannot be reached; the plugin then relies on pulling.

Body
{
  "plugin_version": "1.0.0",
  "platform_version": "…",
  "site_url": "https://shop.example.com",
  "push_url": "https://shop.example.com/wp-json/sitesplaced/v1/updates",
  "shop_name": "…",
  "currency": "INR"
}

GET/connection/updates

Key: connection key · Permission: connection:manage

Returns the updates waiting to go back to the shop. A plugin calls this every 15 minutes as a fallback, with ?limit=50.

Response 200
{ "updates": [ { …update… } ] }

POST/connection/updates/ack

Key: connection key · Permission: connection:manage

The plugin reports which updates it applied and which failed, so they are not sent again.

Body
{
  "applied": ["id"],
  "failed": [ { "id": "…", "error": "…" } ]
}
Response 200
{ "acknowledged": 1 }

GET/openapi.json

Key: none · No permission needed

A machine-readable description of this API in the OpenAPI 3.1 format. No key is needed. Tools can read it to generate a client or to import the routes into an API testing app.

https://sitesplaced.com/api/v1/openapi.json

Webhooks

A webhook is a message SitesPlaced sends to your server when something happens, so you do not have to keep asking. You register an address with POST /webhooks and choose the events you want.

Events

order.created order.paid order.confirmed order.fulfilled order.shipped order.out_for_delivery order.completed order.cancelled order.updated, or * for all of them.

Each delivery carries three headers:

X-SitesPlaced-Event: order.shipped
X-SitesPlaced-Delivery: …
X-SitesPlaced-Signature: sha256=<hex HMAC-SHA256 of the raw body>

Check the signature

Anyone who learns your webhook address can post to it. The signature is how you tell a real delivery from a fake one. It is the HMAC-SHA256 of the raw request body, made with the whsec_… secret you were shown once, written as hex after sha256=.

Work out the same value on your side and compare the two with a constant-time function. A plain === can leak, through how long it takes, how much of a guessed signature was right.

Node
import crypto from "node:crypto";

// rawBody: the exact bytes of the request body (a Buffer or a string), BEFORE
//          any JSON parsing. A body that was parsed and turned back into JSON
//          will not match, because the signature is made from the bytes we sent.
// header:  the X-SitesPlaced-Signature header.
// secret:  the "whsec_…" value you were shown once when you created the webhook.
export function isFromSitesPlaced(rawBody, header, secret) {
  const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(header || "");
  // timingSafeEqual throws when the lengths differ, so check that first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// In your handler:
//   if (!isFromSitesPlaced(rawBody, req.headers["x-sitesplaced-signature"], secret)) -> answer 401
//   otherwise answer 200 quickly (we wait 8 seconds at most), then do the slow work.

How delivery works

  • One attempt per event. A failed delivery is not retried.
  • SitesPlaced waits 8 seconds for your answer. Reply first, then do the slow work.
  • Redirects are not followed. Register the final address.
  • Because of all three, treat webhooks as a fast signal and not as the record. Poll GET /orders?updated_since= on a timer as a backstop, so a missed delivery is caught on the next pass.
  • Orders sent with backfill: true fire no webhooks.

The headless checkout

“Headless” means your own website in front, with SitesPlaced doing the work behind it. Your site has no backend of its own: SitesPlaced keeps the products, takes the payment and holds the orders.

  1. Create a publishable key under Settings → Developer and add your website to its allowed list. The store must be published.
  2. List products with GET /products and GET /categories, and draw them however you like.
  3. When the shopper wants to buy, call POST /checkout/sessions with the product ids, quantities and any variant choices.
  4. Send the shopper to the returned url. The SitesPlaced cart opens with those lines in it. They pay there, with the payment methods the store already has switched on.
  5. Show tracking with GET /tracking or the tracking widget.
Browser JavaScript
// 1. List products. A publishable key is safe in browser code.
const KEY = "sp_live_pk_…";
const api = (path, init = {}) =>
  fetch("https://sitesplaced.com/api/v1" + path, {
    ...init,
    headers: { Authorization: "Bearer " + KEY, "Content-Type": "application/json" },
  }).then((r) => r.json());

const { data: products } = await api("/products?limit=25");

// 2. The shopper presses Buy. Send product ids and quantities, never prices.
const session = await api("/checkout/sessions", {
  method: "POST",
  body: JSON.stringify({
    items: [{ product_id: products[0].id, quantity: 1 }],
    return_url: "https://myshop.com/thanks",
  }),
});

// 3. Send the shopper to the SitesPlaced cart. They pay there.
window.location.href = session.url;

Tracking and the second detail

GET /trackingis safe to call from a browser with a publishable key. It returns the order's status, items, totals, courier and tracking link. It never returns a name, a phone number, an email or an address.

For an order copied in from WooCommerce, Shopify or another system, the order number alone is not enough. The caller must also send contact: the last 4 digits of the phone number on the order, or the email.

The tracking widget

If you do not want to build a tracking form, paste this where the form should appear:

HTML
<div data-sitesplaced-tracking data-key="sp_live_pk_…"></div>
<script async src="https://sitesplaced.com/embed/tracking.js"></script>

It draws an order-number field and, when the order needs one, the second-detail field. It calls GET /tracking and shows the status, the courier and the tracking link. Use a publishablekey, and make sure the page's website is on the key's allowed list.

Connecting a plugin

This is how the WooCommerce plugin connects. You only need it if you are writing a connector of your own. The shop owner never copies a key by hand.

  1. The plugin makes a random state, stores it for 15 minutes, and sends the admin's browser to the connect page. site_url is the shop's home address. return_url is an admin address on the same host.
  2. The shop owner signs in, or signs up. They see the shop's address, choose to create a new connected store or pick one of their stores, and approve.
  3. The browser goes back to return_url?sp_code=spc_…&state=…, or to ?sp_error=…&state=… when the connection did not go through.
  4. The plugin checks that state is the one it made. Then, server to server, it calls POST /connect/exchange and receives its key and its signing secret.
  5. The plugin calls PUT /connection with its version and its push_url.
Step 1: the connect page
https://sitesplaced.com/connect/woocommerce
  ?site_url=…&return_url=…&state=…&name=…&currency=…&v=1.0.0
  • The consent screen names the website being connected, so a crafted link cannot quietly attach someone else's site.
  • The code works once, lasts 10 minutes, and only works for that website's host.
  • Manual fallback: the shop owner creates the connection in the dashboard and pastes the key and the signing secret into the plugin.
  • A 401 on any call means the key was revoked. The plugin pauses and shows a notice.

Updates going back to the shop

An update says: this order shipped, is out for delivery or was delivered, and here is the tracking. It is the only thing SitesPlaced sends back to a connected shop.

An update
{
  "id": "uuid",
  "order_id": "uuid",
  "external_id": "1234",
  "number": "1234",
  "status": "shipped",
  "tracking": { "number": "AWB123", "courier": "Delhivery", "url": "https://…" },
  "tracking_page_url": "https://shop-orders.sitesplaced.site/track-order?order=1234",
  "comment": "Dispatched",
  "updated_at": "2026-09-18T12:00:00.000Z"
}

status is shipped, out_for_delivery, completed, or null when only the tracking changed.

Push. SitesPlaced posts to the plugin's push_url as soon as there is an update:

Request to the shop
POST {push_url}
X-SitesPlaced-Timestamp: <unix seconds>
X-SitesPlaced-Signature: sha256=<hex HMAC-SHA256 of "<timestamp>.<raw body>" with webhook_secret>

{ "updates": [ { …update… } ] }

Note that this signature covers the timestamp and the body joined by a dot, which is different from a webhook's. The plugin must reject a timestamp that is more than 5 minutes off, compare the signature in constant time, and answer:

Response 200 from the shop
{
  "applied": ["id", …],
  "failed": [ { "id": "…", "error": "…" } ]
}

Pull. Every 15 minutes the plugin also calls GET /connection/updates and then POST /connection/updates/ack. This is what keeps tracking flowing when a shop's host blocks incoming requests.

  • Applying the same update twice must change nothing.
  • An update the plugin has just applied must not be sent back to SitesPlaced as a new change. Without this rule the two sides would echo each other for ever.

More in the developer docs: Overview, WooCommerce, Shopify, MCP for AI assistants.

Not a developer? The Help Center explains the same things step by step. Stuck on something? Send us the request id from the reply you got, through the contact page.