Stitchwork Docs
stitchwork.io

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"
}}}
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

customer.created
customer.updated
customer.deleted
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.sentquote.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. 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.

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.

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

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

# 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 variableDefaultEffect
WEBHOOKS_ENABLED1Set to 0 during an incident to stop emission entirely. Nothing is queued while off.
WEBHOOK_MAX_ATTEMPTS6Attempts before dead-lettering.
WEBHOOK_TIMEOUT_SECONDS20Per-attempt HTTP timeout.
WEBHOOK_WORKER_BATCH100Deliveries drained per invocation.
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.