Connect your stack
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.
curl -X POST https://app.stitchwork.io/api/v1/webhooks \
-H "Content-Type: application/json" -b cookies.txt \
-d '{
"url": "https://oms.example.com/hooks/stitchwork",
"event_types": ["quote.accepted", "quote.paid", "order.*"],
"description": "Feed the fulfilment queue"
}'
{"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
customer.created
customer.updated
customer.deleted
| 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. 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. |
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": "ada@example.com",
"…": "…"
}
}
}
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 |
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
# the 50 most recent deliveries for a subscription
curl https://app.stitchwork.io/api/v1/webhooks/3/deliveries -b cookies.txt
# one delivery, with the response body we got back
curl https://app.stitchwork.io/api/v1/webhooks/deliveries/9182 -b cookies.txt
# push it back onto the queue
curl -X POST https://app.stitchwork.io/api/v1/webhooks/deliveries/9182/resend -b cookies.txt
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
# list (secrets omitted)
curl https://app.stitchwork.io/api/v1/webhooks -b cookies.txt
# pause without losing the secret or the filter
curl -X PATCH https://app.stitchwork.io/api/v1/webhooks/3 \
-H "Content-Type: application/json" -b cookies.txt \
-d '{"active": false}'
# change where it points
curl -X PATCH https://app.stitchwork.io/api/v1/webhooks/3 \
-H "Content-Type: application/json" -b cookies.txt \
-d '{"url":"https://oms.example.com/hooks/v2","event_types":["quote.*","order.*"]}'
# new secret — returns it once
curl -X POST https://app.stitchwork.io/api/v1/webhooks/3/rotate-secret -b cookies.txt
# gone for good (owner only)
curl -X DELETE https://app.stitchwork.io/api/v1/webhooks/3 -b cookies.txt
Operating the worker
Relevant if you self-host. The hosted service already runs this.
# by hand, while iterating
php bin/webhook-worker.php
# in production, once a minute
* * * * * cd /var/app/current && php bin/webhook-worker.php >> /var/log/webhook-worker.log 2>&1
Overlapping runs are safe — rows are claimed with FOR UPDATE SKIP LOCKED, so
a second worker sees an empty batch and exits.
| Environment variable | Default | Effect |
|---|---|---|
WEBHOOKS_ENABLED | 1 | Set to 0 during an incident to stop emission entirely. Nothing is queued while off. |
WEBHOOK_MAX_ATTEMPTS | 6 | Attempts before dead-lettering. |
WEBHOOK_TIMEOUT_SECONDS | 20 | Per-attempt HTTP timeout. |
WEBHOOK_WORKER_BATCH | 100 | Deliveries drained per invocation. |
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.