Stitchwork Docs

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"
}}}
This is the only time you see the secret

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_typesMatches
["*"] defaultEverything.
["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
Eventdata keysFired when
quote.createdquote, linesA quote is created — in the app, or through /quotes/external.
quote.updatedquoteDiscount, tax, currency, notes or validity change on a draft.
quote.sent … quote.paidquoteOne event per status transition.
quote.line_addedquote_id, lineA line is added to a draft.
quote.line_updatedquote_id, lineA line's quantity, price, description or SKU changes.
quote.line_removedquote_id, line_idA line is deleted.
quote.deletedquoteHard delete. The payload is a snapshot taken before the row went, so you know which quote is being tombstoned.
order.createdorder, itemsAny new order — import, quote conversion or external.
order.updatedorder, itemsAn upsert that matched an existing order, or a patch from your backend. A re-import fires this, not created.
order.deletedorderHard delete, snapshot payload.
customer.createdcustomerA genuinely new customer.
customer.updatedcustomerProfile change — including an upsert on an existing email, so a repeat buyer in an import fires updated, not created.
customer.deletedcustomerHard delete, snapshot payload.
order.attempt_groupedorderFired only when something was written: this order was filed as another go at a basket already tried. See repeat checkout attempts.
order.recoveredorderThis sale took an abandoned basket off the table.
follow_up.createdfollow_upSomebody — or an AI connection — put a customer on the list.
follow_up.donefollow_upClosed with an outcome. completed_by names who did it.
follow_up.dismissedfollow_upJudged not worth doing, with the reason.
follow_up.settledfollow_upThe 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.updatedfollow_upReassigned, 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.

Three rules

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 failureNext attempt in
1st30 seconds
2nd2 minutes
3rd10 minutes
4th1 hour
5th6 hours
6thdead-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.

Design your receiver for at-least-once

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

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.

Emission can never break a sale

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.