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
- In your store admin, open Settings → Developers → Webhooks and click Add endpoint.
- Enter a publicly reachable HTTPS URL and choose the topics to subscribe to.
- Copy the signing secret (
whsec_…). It is shown once; you can roll it later. - Use Send test event to check your endpoint responds.
Topics
order.createdorder.updatedorder.paidorder.fulfilledorder.cancelledorder.refundedproduct.createdproduct.updatedproduct.deletedinventory.updatedcustomer.createdcustomer.updatedbooking.createdbooking.updatedbooking.cancelledrefund.createdreturn.createdreturn.updateddiscount.createdgift_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).
{
"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
| Header | Description |
|---|---|
X-SellOrchard-Webhook-Id | The event ID (same as id in the body). Stays the same across retries. |
X-SellOrchard-Topic | The topic, e.g. order.paid. |
X-SellOrchard-Timestamp | Unix time (seconds) the delivery was signed. |
X-SellOrchard-Signature | t=<timestamp>,v1=<signature> |
Verifying signatures
Always verify that a delivery came from SellOrchard before acting on it. The signature header looks like this:
t=1793714592,v1=f630746a711bdd047df3d50f14662ee5968b1d1977997e33ac1c258764d4c68f- Split the header on
,and each part on the first=. Taket(the timestamp) and everyv1value. - Build the signed string: the timestamp, a literal
., then the raw request body exactly as received —<t>.<raw body>. - 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). - Compare it to the
v1value with a constant-time comparison. - 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);<?php
/** Returns true when the X-SellOrchard-Signature header matches the raw body. */
function verify_sellorchard_signature(string $payload, string $header, string $secret, int $tolerance = 300): bool
{
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't') $timestamp = $value;
if ($key === 'v1') $signatures[] = $value;
}
if ($timestamp === null || !ctype_digit($timestamp) || $signatures === []) return false;
// Reject old (replayed) deliveries.
if (abs(time() - (int) $timestamp) > $tolerance) return false;
$expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) return true;
}
return false;
}
$payload = file_get_contents('php://input'); // the exact bytes received
$header = $_SERVER['HTTP_X_SELLORCHARD_SIGNATURE'] ?? '';
if (!verify_sellorchard_signature($payload, $header, getenv('SELLORCHARD_WEBHOOK_SECRET'))) {
http_response_code(400);
exit;
}
$event = json_decode($payload, true);
// Queue the work and reply quickly; dedupe on $event['id'].
error_log($event['type'] . ' ' . $event['data']['object']['id']);
http_response_code(200);import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
def verify_signature(payload: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
"""Returns True when the X-SellOrchard-Signature header matches the raw body."""
timestamp, signatures = None, []
for part in (header or "").split(","):
key, _, value = part.strip().partition("=")
if key == "t":
timestamp = value
elif key == "v1":
signatures.append(value)
if not timestamp or not timestamp.isdigit() or not signatures:
return False
# Reject old (replayed) deliveries.
if abs(time.time() - int(timestamp)) > tolerance:
return False
expected = hmac.new(secret.encode(), timestamp.encode() + b"." + payload, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, sig) for sig in signatures)
@app.post("/webhooks/sellorchard")
def sellorchard_webhook():
payload = request.get_data() # the exact bytes received
if not verify_signature(payload, request.headers.get("X-SellOrchard-Signature", ""), os.environ["SELLORCHARD_WEBHOOK_SECRET"]):
abort(400)
event = request.get_json()
# Queue the work and reply quickly; dedupe on event["id"].
print(event["type"], event["data"]["object"]["id"])
return "", 200Check your implementation
With these inputs your code should produce exactly this signature (ignore the timestamp tolerance for this check):
secret: whsec_test_secret
timestamp: 1793714592
raw body: {"id":"evt_test","type":"order.paid"}
signed: 1793714592.{"id":"evt_test","type":"order.paid"}
v1: f630746a711bdd047df3d50f14662ee5968b1d1977997e33ac1c258764d4c68fResponding & 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.