Webhooks
Stitchwork sends a signed HTTP POST whenever a quote, order or customer changes, so your OMS, ERP or workflow tool never has to poll.
Creating a subscription
Manage them at /webhooks in the app, or over the API. One workspace can have as many as it likes, each with its own URL, filter and signing secret.
{"version":"v1","data":{"subscription":{
"id": 3,
"organization_id": 42,
"url": "https://oms.example.com/hooks/stitchwork",
"event_types": ["quote.accepted","quote.paid","order.*"],
"signing_secret": "whsec_2f1c9a7e5b3d84061f2e8c9a7b5d3e10",
"active": true,
"description": "Feed the fulfilment queue",
"created_at": "2026-04-02T09:14:22+00:00",
"updated_at": "2026-04-02T09:14:22+00:00"
}}}
signing_secret is returned on creation and on rotation, and nowhere else.
There is no endpoint that reads it back. A merchant who has lost theirs rotates —
which invalidates verification on their end until they redeploy with the new value.
Event filters
event_types | Matches |
|---|---|
["*"] default | Everything. |
["quote.*"] | Every event whose type starts with quote. |
["quote.accepted"] | Only that exact type. |
["quote.*","order.created"] | Any combination — an event matching any entry is delivered once. |
[] | Nothing. The subscription is effectively silent. (An empty list you send is normalised to ["*"]; storing a genuinely empty list means silence.) |
Event catalogue
quote.created quote.line_added
quote.updated quote.line_updated
quote.sent quote.line_removed
quote.accepted quote.deleted
quote.declined
quote.cancelled
quote.paid
order.created order.deleted
order.updated order.attempt_grouped
order.recovered
customer.created
customer.updated
customer.deleted
follow_up.created follow_up.dismissed
follow_up.updated follow_up.settled
follow_up.done
| Event | data keys | Fired when |
|---|---|---|
quote.created | quote, lines | A quote is created — in the app, or through /quotes/external. |
quote.updated | quote | Discount, tax, currency, notes or validity change on a draft. |
quote.sent … quote.paid | quote | One event per status transition. |
quote.line_added | quote_id, line | A line is added to a draft. |
quote.line_updated | quote_id, line | A line's quantity, price, description or SKU changes. |
quote.line_removed | quote_id, line_id | A line is deleted. |
quote.deleted | quote | Hard delete. The payload is a snapshot taken before the row went, so you know which quote is being tombstoned. |
order.created | order, items | Any new order — import, quote conversion or external. |
order.updated | order, items | An upsert that matched an existing order, or a patch from your backend. A re-import fires this, not created. |
order.deleted | order | Hard delete, snapshot payload. |
customer.created | customer | A genuinely new customer. |
customer.updated | customer | Profile change — including an upsert on an existing email, so a repeat buyer in an import fires updated, not created. |
customer.deleted | customer | Hard delete, snapshot payload. |
order.attempt_grouped | order | Fired only when something was written: this order was filed as another go at a basket already tried. See repeat checkout attempts. |
order.recovered | order | This sale took an abandoned basket off the table. |
follow_up.created | follow_up | Somebody — or an AI connection — put a customer on the list. |
follow_up.done | follow_up | Closed with an outcome. completed_by names who did it. |
follow_up.dismissed | follow_up | Judged not worth doing, with the reason. |
follow_up.settled | follow_up | The customer bought before anybody contacted them, so it retired itself. The one follow-up event with nobody's name on it — no closer, and no credit due. |
follow_up.updated | follow_up | Reassigned, or handed back to unassigned. |
The envelope
Content type application/json. The body is one event:
{
"id": "evt_01H8XM4TQZ9K3B7VYNRPGCDW2F",
"type": "quote.accepted",
"created": 1775123045,
"organization_id": 42,
"data": {
"quote": {
"id": 7,
"quote_number": "Q-2026-000007",
"status": "accepted",
"currency": "GBP",
"subtotal": "2850.00",
"discount_amount": "0.00",
"tax_amount": "475.00",
"total": "2850.00",
"customer_name": "Ada Lovelace",
"customer_email": "[email protected]",
"…": "…"
}
}
}
data.* uses the same shape the JSON API already returns for
that resource — there is no webhook-only serialisation to learn. id is
evt_ plus 26 unambiguous base-32 characters; created is a Unix
timestamp in seconds.
Headers
Content-Type: application/json; charset=utf-8
User-Agent: Stitchwork-Webhooks/1
Stitchwork-Signature: t=1775123045,v1=8f3d…
Stitchwork-Event-Id: evt_01H8XM4TQZ9K3B7VYNRPGCDW2F
Stitchwork-Event: quote.accepted
Stitchwork-Attempt: 1
The signature header shape mirrors Stripe's deliberately, so if you already verify Stripe webhooks you can reuse that code with only the header name changed.
Verifying signatures
The signed string is "{timestamp}.{raw body}", HMAC-SHA256'd with the
subscription's secret and hex-encoded.
1. Verify against the raw request body, before any JSON parsing or re-serialisation — a round-trip through your JSON library changes the bytes and breaks the HMAC. 2. Compare in constant time. 3. Reject timestamps outside a tolerance (300 seconds is the value our own verifier uses) so a captured delivery can't be replayed later.
Node
import crypto from 'node:crypto';
// express: app.post('/hooks/stitchwork', express.raw({type:'application/json'}), handler)
function verify(rawBody, header, secret, toleranceSeconds = 300) {
let ts = null;
const sigs = [];
for (const part of header.split(',')) {
const [k, v] = part.trim().split('=', 2);
if (k === 't') ts = parseInt(v, 10);
if (k === 'v1') sigs.push(v);
}
if (!ts || sigs.length === 0) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - ts) > toleranceSeconds) return false;
const expected = crypto.createHmac('sha256', secret)
.update(`${ts}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected);
return sigs.some(s => {
const b = Buffer.from(s);
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
}
PHP
function stitchwork_verify(string $body, string $header, string $secret, int $tolerance = 300): bool
{
$ts = null; $sigs = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = array_pad(explode('=', trim($part), 2), 2, null);
if ($k === 't') { $ts = (int) $v; }
if ($k === 'v1') { $sigs[] = (string) $v; }
}
if ($ts === null || $sigs === []) return false;
if (abs(time() - $ts) > $tolerance) return false;
$expected = hash_hmac('sha256', $ts . '.' . $body, $secret);
foreach ($sigs as $sig) {
if (hash_equals($expected, $sig)) return true;
}
return false;
}
$body = file_get_contents('php://input');
$header = $_SERVER['HTTP_STITCHWORK_SIGNATURE'] ?? '';
if (!stitchwork_verify($body, $header, getenv('STITCHWORK_WEBHOOK_SECRET'))) {
http_response_code(400);
exit;
}
Python
import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
ts, sigs = None, []
for part in header.split(','):
k, _, v = part.strip().partition('=')
if k == 't': ts = int(v)
if k == 'v1': sigs.append(v)
if ts is None or not sigs:
return False
if abs(int(time.time()) - ts) > tolerance:
return False
signed = f"{ts}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in sigs)
Bash, for a one-off check
t=1775123045
body='{"id":"evt_…"}'
secret='whsec_…'
echo -n "$t.$body" | openssl dgst -sha256 -hmac "$secret" | awk '{print $2}'
# compare against the v1= value in the header
Delivery and retries
Every event that matches a subscription is enqueued as a delivery row. A worker drains the queue, POSTs the signed body, and records the outcome. Any 2xx is success; anything else — including a network error or a timeout — is a failure that gets retried.
| After failure | Next attempt in |
|---|---|
| 1st | 30 seconds |
| 2nd | 2 minutes |
| 3rd | 10 minutes |
| 4th | 1 hour |
| 5th | 6 hours |
| 6th | dead-lettered |
Each attempt allows your endpoint 20 seconds to answer. Acknowledge first and do the slow work afterwards — a receiver that finishes the whole job before replying is the usual cause of a retry storm against a system that was, in fact, fine.
A delivery ends in one of four statuses: pending, succeeded,
failed (will retry) or dead (won't). A subscription that is
deleted or paused after an event was enqueued causes its pending deliveries to be marked
dead rather than retried forever.
Delivery order is not guaranteed, and a retry after a network error
can land as a second copy of an event your endpoint already processed. Dedupe on
id (also available as the Stitchwork-Event-Id header) and
make your handler idempotent.
A well-behaved receiver
- Respond fast. The worker holds a row lock for the length of the HTTP call, with a 20-second timeout. Acknowledge with 2xx and do the work asynchronously.
- Return 2xx for events you don't care about. A 404 on an unrecognised type is a failure and gets retried five more times.
- Don't redirect. Redirects are not followed. Subscribe the final URL.
- Use HTTPS.
http://is accepted for local development, but the body contains customer data.
Inspecting deliveries
A delivery row records the attempt count, the last status code, the last error, and the first 8 KB of the response body — which is usually enough to see your own stack trace and skip a round of guessing.
Managing subscriptions
Settings → Webhooks lists your subscriptions with their event types, their recent delivery history and the failures worth looking at. From there you can add one, change which events it wants, pause it, rotate its signing secret, resend a single delivery, or delete it. Rotating the secret takes effect on the next delivery, so update your endpoint first.
What we run, so you don't
Delivery is ours: the queue, the worker, the retry schedule and the dead-letter handling
all run inside Stitchwork. There is nothing for you to host, schedule or monitor. Your
side of a webhook integration is one HTTPS endpoint that returns a
2xx.
That matters more than it sounds. The common way an OMS integration rots is a cron job on somebody's box that quietly stopped a fortnight ago. Here the delivery attempts, their responses and their retries are all visible to you in Settings → Webhooks, and a delivery you resend replays the exact original payload rather than a fresh snapshot — so a replay tells you the truth about what happened, not what is true now.
A failure inside the emitter — a broken subscription table, an unreachable database — is swallowed and logged. A quote or an order is never lost because a webhook couldn't be queued.