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

  • POST with Content-Type: application/json, over HTTPS only.
  • Idempotency-Key is the order id, and it is identical on every attempt. Use it to make retries safe.
  • X-SimplePath-Signature is v1= 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-Event repeats 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. A credential is only ever sent to the address it was saved for, so the destination must be on that same address.

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. Carries refundedCents. 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.

Telling us what happened

Every payload carries callbacks.status. When you ship, deliver or cancel a line, POST there and the shop owner sees it on that line in their workspace, and the buyer is emailed the tracking number when you report shipped. Sign it exactly as you verify ours: an HMAC-SHA256 of the raw body with the same signing secret, in X-SimplePath-Signature.

POST https://simplepath.ai/api/store/handoff/status
Content-Type: application/json
X-SimplePath-Signature: v1=<HMAC-SHA256 of the raw body, hex>

{
  "orderId": "cs_live_abc123",
  "lines": [
    { "productId": "merch-hoodie", "externalId": "4711", "status": "shipped",
      "carrier": "USPS", "tracking": "9400 1000 0000 0000 0000 00" }
  ]
}
  • status is one of sent, shipped, delivered, canceled.
  • Name the line by productId, by externalId, or both, and add variantId when the order had a choice. Only lines that carry your source can be changed by you; a report naming anything else is ignored and the answer says how many lines were matched.
  • tracking, carrier and note are optional. The note is shown to the owner, never to the buyer.
  • The answer is 200 { "ok": true, "matched": 1, "ignored": 0 }. A bad signature is 401; an order we do not know is 404.

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"
  },
  "callbacks": {
    "status": "https://simplepath.ai/api/store/handoff/status"
  }
}

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 recognize.