Order handoff
When a SimplePath shop sells something the owner brought in from a supplier, we POST the order to a web address the owner chose. This page is everything you need to receive it.
The request
POSTwithContent-Type: application/json, over HTTPS only.Idempotency-Keyis the order id, and it is identical on every attempt. Use it to make retries safe.X-SimplePath-Signatureisv1=followed by the hex HMAC-SHA256 of the raw request body, keyed with the signing secret shown to the site owner when they set this up.X-SimplePath-Eventrepeats the event name, so you can route without parsing.- If the owner added a credential, it is sent the way that service expects it: a bearer token, a named header, or a query parameter.
Answer with a 2xx
Any 2xx means we are done. A 5xx or 429 is treated as temporary and retried with a widening gap for up to five days. Anything else, including a redirect, is treated as final and shown to the site owner with whatever text you returned, so a short plain-English reason in the body is worth sending.
We never follow redirects. The request carries a customer’s address, so it goes to the address we checked and nowhere else.
Events
order.paid— money has arrived. This is the one to act on.order.refunded— the sale was refunded. CarriesrefundedCents. Stop the shipment if you still can.order.test— the owner pressed the test button. Accept it and create nothing.
Every line is sent
A shop can sell your products and its own on one order. We do not guess which lines are yours: every line arrives, each carrying source (the name the owner gave your connection) or null. Filter on source, and you can still see the whole order, which is usually what the packing slip needs.
The buyer's email may be missing
Site owners can switch it off. When they do, buyer.email is absent rather than empty, so do not depend on it. Name and delivery address are always sent.
Example payload
{
"version": 1,
"event": "order.paid",
"sentAt": "2026-09-07T14:02:11.000Z",
"order": {
"id": "cs_live_abc123",
"paidAt": "2026-09-07T14:02:10.000Z",
"currency": "usd",
"subtotalCents": 6998,
"shippingCents": 0,
"taxCents": 0,
"discountCents": 0,
"totalCents": 6998
},
"buyer": {
"name": "Ann Buyer",
"email": "ann@example.com",
"shipping": {
"name": "Ann Buyer",
"line1": "1 Example Street",
"line2": "",
"city": "Cumming",
"state": "GA",
"postalCode": "30040",
"country": "US"
}
},
"lines": [
{
"productId": "merch-hoodie",
"externalId": "4711",
"source": "MY_PRINTER",
"variantId": null,
"name": "Signature Hoodie",
"option": "L",
"quantity": 1,
"unitCents": 6499
},
{
"productId": "signed-book",
"externalId": null,
"source": null,
"variantId": null,
"name": "Signed book",
"option": null,
"quantity": 1,
"unitCents": 499
}
],
"site": {
"projectId": "abc12345",
"name": "Example Shop",
"url": "https://example.com"
}
}Verifying the signature
Compare in constant time, and verify the raw body before you parse it.
// Node. Verify BEFORE parsing: the signature covers the raw bytes.
const crypto = require('crypto')
function verify(rawBody, headerValue, signingSecret) {
const expected = 'v1=' + crypto
.createHmac('sha256', signingSecret)
.update(rawBody, 'utf8')
.digest('hex')
const a = Buffer.from(expected)
const b = Buffer.from(headerValue || '')
return a.length === b.length && crypto.timingSafeEqual(a, b)
}Versioning
version is 1. We will not remove or rename a field in version 1; anything new arrives as an additional field, so parse tolerantly and ignore what you do not recognise.