SellOrchard

Guides

Webhooks

Webhooks push an event to your server as soon as something changes in your store, so you don’t have to poll.
On this page

Setting up an endpoint

  1. In your store admin, open Settings → Developers → Webhooks and click Add endpoint.
  2. Enter a publicly reachable HTTPS URL and choose the topics to subscribe to.
  3. Copy the signing secret (whsec_…). It is shown once; you can roll it later.
  4. Use Send test event to check your endpoint responds.

Topics

  • order.created
  • order.updated
  • order.paid
  • order.fulfilled
  • order.cancelled
  • order.refunded
  • product.created
  • product.updated
  • product.deleted
  • inventory.updated
  • customer.created
  • customer.updated
  • booking.created
  • booking.updated
  • booking.cancelled
  • refund.created
  • return.created
  • return.updated
  • discount.created
  • gift_card.issued

inventory.updated is batched: at most one event per variant per minute, carrying the latest levels.

Payload

Each delivery is a POST with a JSON body. data.object is the same shape the API returns for that resource (see the reference).

order.paid
{
  "id": "evt_01JC2M9X4B7QK3T8V5W6Y1Z0AR",
  "type": "order.paid",
  "created_at": "2026-11-02T14:03:12Z",
  "api_version": "2026-11",
  "store_id": "01JBX4Q2ZK8V7Y5N3M1P0R9S6T",
  "data": {
    "object": {
      "object": "order",
      "id": 5521,
      "number": 1042,
      "name": "#1042",
      "status": "processing",
      "payment_status": "paid",
      "fulfillment_status": "unfulfilled",
      "source": "storefront",
      "test": false,
      "email": "ann@example.com",
      "phone": "+15125550199",
      "customer_id": 880,
      "customer_name": "Ann Lee",
      "currency": "USD",
      "subtotal": "48.00",
      "discount_total": "4.80",
      "shipping_total": "5.00",
      "tax_total": "3.17",
      "total": "51.37",
      "refunded_total": "0.00",
      "discount_code": "WELCOME10",
      "payment_method": "stripe",
      "payment_method_label": "Card",
      "line_items": [
        {
          "id": 9001,
          "product_id": 120,
          "variant_id": 311,
          "name": "Organic cotton tee",
          "variant_title": "Blue / Large",
          "sku": "TEE-BL-L",
          "kind": "physical",
          "quantity": 2,
          "unit_price": "24.00",
          "discount": "4.80",
          "tax": "3.17",
          "line_total": "48.00",
          "fulfilled_at": null
        }
      ],
      "fulfillment": "ship",
      "shipping_line": {
        "title": "Standard",
        "price": "5.00"
      },
      "shipping_address": {
        "first_name": "Ann",
        "last_name": "Lee",
        "company": null,
        "line1": "1 Main St",
        "line2": "Apt 2",
        "city": "Austin",
        "region": "TX",
        "postcode": "73301",
        "country": "US",
        "phone": "+15125550199"
      },
      "billing_address": {
        "first_name": "Ann",
        "last_name": "Lee",
        "company": null,
        "line1": "1 Main St",
        "line2": "Apt 2",
        "city": "Austin",
        "region": "TX",
        "postcode": "73301",
        "country": "US",
        "phone": "+15125550199"
      },
      "tracking": null,
      "tags": [
        "wholesale"
      ],
      "note": "Gift wrap",
      "customer_note": "Please leave at the door.",
      "location_id": 1,
      "placed_at": "2026-11-02T15:00:00Z",
      "paid_at": "2026-11-02T15:00:00Z",
      "fulfilled_at": null,
      "delivered_at": null,
      "cancelled_at": null,
      "created_at": "2026-11-02T15:00:00Z",
      "updated_at": "2026-11-03T09:30:00Z"
    }
  }
}

Headers

HeaderDescription
X-SellOrchard-Webhook-IdThe event ID (same as id in the body). Stays the same across retries.
X-SellOrchard-TopicThe topic, e.g. order.paid.
X-SellOrchard-TimestampUnix time (seconds) the delivery was signed.
X-SellOrchard-Signaturet=<timestamp>,v1=<signature>

Verifying signatures

Always verify that a delivery came from SellOrchard before acting on it. The signature header looks like this:

X-SellOrchard-Signature
t=1793714592,v1=f630746a711bdd047df3d50f14662ee5968b1d1977997e33ac1c258764d4c68f
  1. Split the header on , and each part on the first =. Take t (the timestamp) and every v1 value.
  2. Build the signed string: the timestamp, a literal ., then the raw request body exactly as received — <t>.<raw body>.
  3. Compute an HMAC-SHA256 of that string using your endpoint’s signing secret (the full whsec_… value) as the key, and hex-encode it (lowercase).
  4. Compare it to the v1 value with a constant-time comparison.
  5. Reject the delivery if the timestamp is more than 5 minutes from your server’s clock, to stop replays.
Verify against the raw bytes. Parsing the JSON and serialising it again changes whitespace and key order, and the signature will no longer match. In frameworks that parse bodies automatically, read the raw body for this route.
import crypto from "node:crypto";
import express from "express";

const app = express();

/** Returns true when the X-SellOrchard-Signature header matches the raw body. */
function verifySignature(rawBody, header, secret, toleranceSec = 300) {
  let timestamp;
  const signatures = [];
  for (const part of (header ?? "").split(",")) {
    const [key, value] = part.trim().split("=");
    if (key === "t") timestamp = value;
    if (key === "v1") signatures.push(value);
  }
  if (!timestamp || !/^\d+$/.test(timestamp) || signatures.length === 0) return false;

  // Reject old (replayed) deliveries.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSec) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.`)
    .update(rawBody) // the exact bytes received — not re-serialised JSON
    .digest("hex");

  return signatures.some(
    (sig) => sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)),
  );
}

// express.raw keeps the body as a Buffer so the signature can be checked.
app.post("/webhooks/sellorchard", express.raw({ type: "application/json" }), (req, res) => {
  const ok = verifySignature(req.body, req.get("X-SellOrchard-Signature"), process.env.SELLORCHARD_WEBHOOK_SECRET);
  if (!ok) return res.sendStatus(400);

  const event = JSON.parse(req.body.toString("utf8"));
  // Queue the work and reply quickly; dedupe on event.id.
  console.log(event.type, event.data.object.id);
  res.sendStatus(200);
});

app.listen(3000);

Check your implementation

With these inputs your code should produce exactly this signature (ignore the timestamp tolerance for this check):

Test vector
secret:    whsec_test_secret
timestamp: 1793714592
raw body:  {"id":"evt_test","type":"order.paid"}
signed:    1793714592.{"id":"evt_test","type":"order.paid"}
v1:        f630746a711bdd047df3d50f14662ee5968b1d1977997e33ac1c258764d4c68f

Responding & retries

  • Return any 2xx status within 10 seconds to acknowledge a delivery. Anything else — or a timeout — counts as a failure.
  • Failed deliveries are retried up to 8 attempts in total, waiting 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours, 24 hours between attempts.
  • If an endpoint keeps failing for 3 days it is disabled automatically and the store owner is emailed. Re-enable it from the admin once it’s fixed.
  • Delivery history (status, response code, attempts, timing) is kept for 30 days in Settings → Developers → Webhooks, where you can also Resend any delivery.

Testing

Use Send test event on an endpoint to deliver a sample payload for any topic, signed with the endpoint’s real secret. During development, a tunnelling tool can expose your local server over HTTPS.

Best practices

  • Reply fast, work later. Store the event and process it in a background job.
  • Expect duplicates. Retries and resends reuse the same event id; skip IDs you’ve already handled.
  • Don’t rely on order. Events can arrive out of order. Compare updated_at, or fetch the latest object from the API when it matters.
  • Keep the secret secret. Roll it from the admin if it leaks.