Receiving a signed webhook
StitchAPI does not receive webhooks — that is your server. Here is exactly where the line falls, measured, and what the library does own on the far side of it.
The problem
Stripe, GitHub, Slack push events at an endpoint you host. You verify the signature, decide it's genuine, and act. This is the only scenario in this section where someone is calling you — and the stitch is a per-call primitive, not a server: inbound webhooks are your application's job.
So this page is about where the line falls, and what's on each side of it.
What makes the receipt half hard:
- Signature verification needs the exact bytes. The provider signs the raw payload; any
JSON round-trip yields logically identical bytes that don't match. This is the famous
express.raw()-before-express.json()rule, and the most-reported webhook bug there is. - At-least-once means duplicates are normal. Dedup on the event id, with a TTL longer than the provider's retry window (Stripe: 3 days).
- Order cannot be trusted.
subscription.updatedcan arrive before.created. - Ack fast. Providers time out in seconds and retry on slowness.
The common solutions
| Approach | Where it breaks |
|---|---|
Provider SDK verifier (constructEvent) | Correct, and the right answer per vendor. One per provider. |
| Framework raw-body middleware | The standard fix, and entirely about ordering — get it wrong and it fails silently. |
| Hand-rolled HMAC | Fine, and easy to get subtly wrong: constant-time compare, timestamp tolerance. |
| Webhook gateway (Svix, Hookdeck) | Most complete at scale. A third party in the path, and a bill. |
| Dedup on the event id | Necessary. The TTL is the trap — it must outlive the retry window. |
| Fetch-on-receipt | Makes payload order irrelevant. Costs an API call per event. |
Where the line falls
serve is not a webhook endpoint, and the measurements are unambiguous. A real serve()
process answered 404 to a correctly signed Stripe POST at /webhooks/stripe, /webhook,
/, /stitch and /hooks/v1/billing — the route table is exactly GET / and
POST /stitch/:name. On the one route that reaches user code:
- the body has already been through
JSON.parse, and the deepest user-reachable seam got a parsed object with no raw string anywhere; - the inbound headers are dropped entirely —
stripe-signaturereaches nothing, so there is no signature to verify even if you had the bytes; - a form-encoded provider (Slack) is rejected
400 invalid JSON bodybefore any of it.
The byte gap is real and measured: the provider signed 162 bytes; a parse→stringify
round-trip produces 153 logically identical bytes, and verification returns
bad-signature.
There is also no inbound-signature primitive anywhere in the packages. Enumerating all 72
runtime exports, the four that match /verif|hmac|…/ are BYO-plugin conformance suites. The
one HMAC in the repo is @stitchapi/aws-sigv4, whose key is imported with
usages: ['sign'] — it structurally cannot verify.
What StitchAPI does own
Everything after the ack, and it is worth having:
const billing = seam({
baseUrl: 'https://api.example.com',
auth: bearer(env('API_KEY')),
throttle: { rate: '10/s' },
});
// Fetch-on-receipt: the payload is a hint; the API is the truth.
const getSubscription = billing.stitch({
path: '/subscriptions/{id}',
retry: { attempts: 3 },
timeout: { total: '10s' },
});Measured: acting on payload order with a reversed pair left the app believing
trialing/free while the server said active/pro — a silent, self-inflicted downgrade.
Fetch-on-receipt converged both events on active/pro at a cost of 2 calls for 2 events, and
the call survived two 503s in 3 attempts with the retry, backoff, auth and deadline all as
config rather than handler code.
The dedup ledger is also first-class, and the store is yours to supply — redisStore,
cloudflareKvStore and denoKvStore all implement the same interface, so durability is a
one-line change and verifyStoreContract proves a BYO one conforms (measured: all 11 rules, 0
violations).
Use increment, not get-then-set, to claim an event id. Measured
with 3 concurrent deliveries of one id: get+set returned [true, true, true] — three charges — while increment(key, ttl) returned [true, false, false], exactly one. The default memoryStore is also not durable:
close() clears it, so a deploy inside the retry window re-processes
everything still in flight.
The boundary, as a number
A complete, honest implementation — a node:http server you own, plus StitchAPI downstream —
rejects forged signatures (400 bad-signature), rejects a genuine MAC on a 10-minute-old
timestamp (400 stale, zero side effects), converges a reversed pair, and acks a duplicate at
zero API calls.
| half | lines | what it is |
|---|---|---|
| Receipt | 154 (96 server + 58 node:crypto) | imports nothing from stitchapi at runtime — one import type |
| Reaction | 63 | almost all config: one seam, two stitches, a 4-line version guard |
71% of the code by line, 100% by concern, is the half StitchAPI does not participate in. That is the honest shape of this scenario, and it is by design.
What StitchAPI does not solve here
- Route ownership, raw bytes, and the inbound headers. All three are absent from
serve. - HMAC verification and constant-time comparison. No primitive exists.
xxh128is unkeyed and non-cryptographic — it will produce a plausible digest that authenticates nobody. - Replay/timestamp tolerance. Yours.
- Non-JSON bodies.
serverejects them before any user code. - Any queue, outbox, dead-letter or backpressure.
pipelineStageson a maximally configured stitch shows no stage matching queue/detach/defer —resultis last, and the pipeline never releases a caller early. - The ack/work split. Worse:
serveconsumes the run to completion before writing a byte, so withretry: { attempts: 3 }and a 5 s backoff the ack went out only at attempt 3, after exactly 10 virtual seconds — Stripe's timeout to the second. The retry policy manufactures the duplicate it was meant to survive. - Write-order conflict. Fetch-on-receipt fixes payload order, not write order: two concurrent handlers with snapshots v2 and v3 landed on v2 under last-write-wins. A version guard recovered it — four lines the library does not express.
.deletedevents. The fetch returns 404, indistinguishable from "never existed", so the event type stays load-bearing.
void call(input) is a no-op, not fire-and-forget. It is the spelling anyone reaches
for to ack-then-continue, and it made 0 HTTP calls and raised 0 errors — a stitch call
is a lazy thenable that starts on .then, so the work is silently dropped after the
provider was told 200. void call(input).then(…) does run it, and is then unsupervised:
unhandled it surfaced as an unhandledRejection, and .safe() reported the failure
nowhere.
And serve is unauthenticated by design. An unsigned, forged body under the size cap
ran the stitch and returned 200. Anyone who can reach the port can run your stitches — it
is a local front door, not an internet-facing endpoint.
See also
The upload you must clean up after
Multipart upload is four steps, and the fourth — abort on failure — is the one no HTTP client models. Skip it and the parts bill forever, invisibly.
One customer's revoked token, everyone's outage
Tokens and caches isolate per tenant automatically. Rate budgets and circuit breakers do not — they isolate only by a string you have to remember to write.