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/v1Always 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
| Key | Looks like | Where it may live | What it can do |
|---|---|---|---|
| Secret | sp_live_sk_ + 48 hex characters | Servers only | Whatever permissions it was given |
| Publishable | sp_live_pk_ + 48 hex characters | Browser code | products:read, checkout:write and tracking:read only, and only from the websites on its allowed list |
| Legacy (Zapier) | sp_live_ + 40 hex characters | Servers only | orders: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
Originheader together withSec-Fetch-Mode. - A publishable key sent with an
Originthat 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.
| Permission | What it allows | Ticked by default on a new secret key | A publishable key may hold it | A plugin's connection key has it |
|---|---|---|---|---|
orders:read | Read orders | Yes | No | Yes |
orders:write | Create and update orders | Yes | No | Yes |
products:read | Read products | Yes | Yes | No |
products:write | Send products from another platform | No | No | Yes |
checkout:write | Prepare a checkout for a shopper | Yes | Yes | No |
tracking:read | Look up order tracking | Yes | Yes | Yes |
webhooks:write | Manage webhooks | No | No | No |
connection:manage | Manage a connected store | No | No | Yes |
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) andCache-Control: no-store.
Rate limits
| Key | Free plan | Paid store plan |
|---|---|---|
| Secret key | 60 requests a minute per key | 300 requests a minute per key |
| Publishable key | 600 a minute per key, and 30 a minute per visitor IP | 3,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.
| HTTP | code | When |
|---|---|---|
| 400 | invalid_request | The request is malformed or a field has a bad value. param names the field. |
| 401 | unauthorized | The key is missing, wrong or revoked. |
| 403 | forbidden | The key is real but may not do this. |
| 404 | not_found | No such order, product or webhook in this store. |
| 409 | conflict | The request clashes with the current state, for example cancelling another platform's order. |
| 413 | payload_too_large | The body is over 1 MB. |
| 422 | unprocessable | The request is well formed but cannot be carried out, for example an unknown product in a cart. |
| 429 | rate_limited | Too many requests. Wait for Retry-After seconds. |
| 500 | internal_error | Something went wrong on our side. |
| 503 | unavailable | Try 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"
}| Field | Values |
|---|---|
source | native (the SitesPlaced checkout), woocommerce, shopify or api |
status | pending, confirmed, shipped, out_for_delivery, completed (this means delivered) or cancelled |
payment.status | unpaid, pending, paid, failed or refunded |
payment.method | For 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_url | The public tracking page for this order. |
risk | null, 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:
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:
{
"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, andtotals.totalas a number of 0 or more. Everything else is optional. status_kindsays how to readstatus.woocommerce: WooCommerce status slugs, with or without thewc-prefix.shopify:open,partial,fulfilledorcancelled.sitesplaced: the six statuses above, plus the aliasesdelivered,fulfilled,processing,paidandcanceled. A connection key defaults to its own platform. Any other key defaults tositesplaced.payment.statusisunpaid,pending,paid,failed,refundedorpartially_refunded. Send it. If it is missing, the server guesses from the order status.payment.is_codistruefor cash on delivery. This is what turns on the cash-on-delivery risk flags.- Not orders: the WooCommerce statuses
checkout-draft,auto-draft,draftandtrashare skipped, and so is anything sent withtest: true. The response isaction: "skipped". deleted: truearchives the copy. An order is never hard-deleted.- Stale updates are ignored. If
updated_atis older than the copy SitesPlaced holds, nothing changes and the response isaction: "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 givesshopify, and any other secret key givesapi.
The response is 201 when the order was created and 200 otherwise:
{
"order": { …order object… },
"action": "created"
}action is created, updated, unchanged or skipped. When it is skipped, order is null and reason says why:
{
"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.
{
"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.
{
"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 path | Key | Permission |
|---|---|---|
| GET /me | any | — |
| GET /orders | secret | orders:read |
| POST /orders | secret | orders:write |
| POST /orders/batch | secret | orders:write |
| GET /orders/{id} | secret | orders:read |
| PATCH /orders/{id} | secret | orders:write |
| DELETE /orders/{id} | secret | orders:write |
| POST /external-products/batch | connection key | products:write |
| DELETE /external-products/{external_id} | connection key | products:write |
| GET /products | secret or publishable | products:read |
| GET /products/{id} | secret or publishable | products:read |
| GET /categories | secret or publishable | products:read |
| POST /checkout/sessions | secret or publishable | checkout:write |
| GET /tracking | secret or publishable | tracking:read |
| GET /webhooks | secret | webhooks:write |
| POST /webhooks | secret | webhooks:write |
| DELETE /webhooks/{id} | secret | webhooks:write |
| POST /connect/exchange | none (one-time code) | — |
| POST /connect/disconnect | connection key | connection:manage |
| GET /connection | connection key | connection:manage |
| PUT /connection | connection key | connection:manage |
| GET /connection/updates | connection key | connection:manage |
| POST /connection/updates/ack | connection key | connection:manage |
| GET /openapi.json | none | — |
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.
curl https://sitesplaced.com/api/v1/me \
-H "Authorization: Bearer sp_live_sk_…"{
"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.
| Query | Meaning |
|---|---|
| limit | 1 to 100. Default 25. |
| cursor | The next_cursor from the page before. |
| status | One of the six order statuses. |
| source | native, woocommerce, shopify or api. |
| external_id | The order's id on the other platform. |
| updated_since | An ISO 8601 date. Only orders updated since then. |
| created_since | An ISO 8601 date. Only orders created since then. |
| include_archived=true | Also return archived orders. They are left out by default. |
curl "https://sitesplaced.com/api/v1/orders?status=confirmed&updated_since=2026-09-18T00:00:00Z&limit=50" \
-H "Authorization: Bearer sp_live_sk_…"{
"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.
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=.
curl https://sitesplaced.com/api/v1/orders/0b8c…uuid \
-H "Authorization: Bearer sp_live_sk_…"{ "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.
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"
}'{ "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.
curl -X DELETE https://sitesplaced.com/api/v1/orders/0b8c…uuid \
-H "Authorization: Bearer sp_live_sk_…"{ "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.
{
"products": [
{
"external_id": "88",
"name": "Silver hoop earrings",
"sku": "HOOP-S",
"price": 1400,
"currency": "INR",
"image_url": "https://…",
"category": "Earrings",
"stock": 12
}
]
}{
"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.
{ "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.
curl "https://sitesplaced.com/api/v1/products?category=Earrings&limit=25" \
-H "Authorization: Bearer sp_live_pk_…"{
"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.
{ "product": { …product object… } }GET/categories
Key: secret or publishable · Permission: products:read
Returns the store's category names. Pass one to GET /products?category=.
{ "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.
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"
}'{
"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.
curl "https://sitesplaced.com/api/v1/tracking?order=1234&contact=6363" \
-H "Authorization: Bearer sp_live_pk_…"{
"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 get422withparam: "contact". If it is wrong you get422. Five wrong tries give429for 15 minutes.
{
"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.
{
"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.
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"] }'{
"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.
{ "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.
POST https://sitesplaced.com/api/v1/connect/exchange
{ "code": "spc_…", "site_url": "https://shop.example.com" }{
"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.
{ "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.
{
"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.
{
"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.
{ "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.
{
"applied": ["id"],
"failed": [ { "id": "…", "error": "…" } ]
}{ "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.jsonWebhooks
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.
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: truefire 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.
- Create a publishable key under Settings → Developer and add your website to its allowed list. The store must be published.
- List products with
GET /productsandGET /categories, and draw them however you like. - When the shopper wants to buy, call
POST /checkout/sessionswith the product ids, quantities and any variant choices. - 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. - Show tracking with
GET /trackingor the tracking widget.
// 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:
<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.
- The plugin makes a random
state, stores it for 15 minutes, and sends the admin's browser to the connect page.site_urlis the shop's home address.return_urlis an admin address on the same host. - 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.
- The browser goes back to
return_url?sp_code=spc_…&state=…, or to?sp_error=…&state=…when the connection did not go through. - The plugin checks that
stateis the one it made. Then, server to server, it calls POST /connect/exchange and receives its key and its signing secret. - The plugin calls PUT /connection with its version and its
push_url.
https://sitesplaced.com/connect/woocommerce
?site_url=…&return_url=…&state=…&name=…¤cy=…&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
401on 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.
{
"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:
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:
{
"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.