AnyHook's ingress is a Cloudflare Worker. Every inbound webhook the service receives lands there first, gets its signature verified, gets persisted, and gets a 200 back to the sender in under 50 milliseconds. That has been running in production for a while now, and the things that were awkward were not the things I expected.
This is the receiver-side companion to Receiving Webhooks on Vercel Without Losing Them. The failure modes are genuinely different, and most of them come from Workers not being Node.
TL;DR
node:cryptois not there by default. Signature verification goes through WebCrypto, which is async- Read the body once, as bytes, before anything parses it, same as everywhere
- Enforce your own payload size limit while streaming, because reading a large body to find out it is large is the wrong order
ctx.waitUntilextends the request past the response but is not durable, exactly like Vercel's- The CPU budget is small and separate from wall-clock time. Awaiting a slow API does not spend it; a tight loop over a large payload does
- A Worker is an excellent place to acknowledge and persist, and a poor place to do your business logic
Signature verification is async and that changes your control flow
On Node you write a synchronous verifier and call it early. WebCrypto's methods return promises, so the same logic becomes async, and the temptation is to await it after you have already parsed the body for routing. Don't. Verification comes before anything that trusts the payload.
Here is the shape our ingress uses to decrypt a stored provider secret before verifying with it. Everything is crypto.subtle:
const keyMaterial = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(envKey.padStart(32, "0").slice(0, 32)),
"AES-GCM",
false,
["decrypt"],
);
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv, tagLength: 128 },
keyMaterial,
dataToDecrypt,
);
Two details that cost time if you meet them the hard way.
WebCrypto's AES-GCM expects the auth tag appended to the ciphertext, while Node's createDecipheriv takes it separately via setAuthTag. If you encrypted on Node and are decrypting on a Worker, you have to concatenate them yourself. Nothing tells you this; you just get an OperationError.
There is no timingSafeEqual. For HMAC verification, prefer crypto.subtle.verify, which does the comparison for you in constant time, over computing the digest and comparing strings.
You can turn on nodejs_compat and get a node:crypto shim, and for a straightforward HMAC that is a reasonable shortcut. But the shim is a compatibility layer over the same primitives, and building against WebCrypto directly means the verification code also runs unchanged in Deno, Bun, and the browser.
Read the body as bytes, and stop early if it is too big
The raw-body rule is universal: the HMAC covers the exact bytes that arrived, so parse nothing before you verify. await request.text() or await request.arrayBuffer() first.
What is specific to an edge receiver is that you are the front door for anyone on the internet who has your URL, and buffering an arbitrarily large body before deciding you did not want it is how you get billed for someone else's traffic. Our ingress checks content-length first, then enforces the limit again while streaming, because content-length is a claim from the client rather than a fact:
const contentLength = request.headers.get("content-length");
if (contentLength && parseInt(contentLength, 10) > limitBytes) return null;
const reader = request.body?.getReader();
const chunks: Uint8Array[] = [];
let totalBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
if (totalBytes > limitBytes) {
reader.cancel(); // stop pulling bytes we have decided to reject
return null;
}
chunks.push(value);
}
reader.cancel() is the part worth copying. Without it you finish downloading a payload you have already decided to refuse.
The cheapest gate goes first
An edge receiver is a public endpoint, and the ordering of your checks is a cost decision as much as a security one.
Ours goes: reject requests missing the headers the claimed provider always sends, then verify the signature if a secret is configured, then count the event against quota. A request with no Stripe-Signature header claiming to be a Stripe webhook is rejected before any crypto runs and before anything is counted. Attackers without the right headers burn essentially nothing.
That ordering only works if the cheap check really is cheap. Header presence is a map lookup. Signature verification is a key import plus an HMAC. Quota is a KV read. Put them in that order and the expensive operations only run on traffic that has already earned them.
Rate limits sit underneath all of it, per IP and per app, plus a short negative cache for slugs that do not exist so that scanning for valid endpoints does not turn into a bill.
waitUntil is not a queue, again
ctx.waitUntil keeps the Worker alive past the response so background work can finish. It is genuinely useful and it is not durability.
If the isolate is evicted or the request is cancelled mid-flight, the work is gone. There is no retry, no dead letter, no record. And you have already returned 200, so the sender considers the event delivered and will never send it again. That event now exists nowhere.
This is the identical trap as Vercel's waitUntil, and the identical rule applies: persist before you acknowledge. Write the event somewhere durable, return 200, then process.
We use waitUntil for exactly one class of thing, which is incrementing counters that we can afford to lose. If a rejection counter under-reports by three during an eviction, nobody is harmed. If an event payload is lost, someone is.
For anything that must survive, the options are a queue, a durable store, or both. Our ingress publishes to a managed queue and, if that publish fails, falls back to writing the event directly to Postgres. If both fail, we return 503 rather than 200, so the sender's own retry becomes the last line of defence. That decision is worth stating explicitly: returning 200 for an event you could not store anywhere is a lie that costs you the event.
CPU time is not wall-clock time
Workers bill and limit CPU time, not the time your request spends waiting. This confuses people coming from Lambda, where the two are the same thing.
Awaiting a database write or an upstream API does not consume CPU. Parsing a two-megabyte JSON payload does. Hashing a large body does. A loop over ten thousand array elements does.
For a webhook receiver this is mostly good news, because the shape you want anyway is almost entirely I/O: read bytes, verify, write, respond. The operations that will get you are the ones people add without thinking, like JSON.parse on a payload you only needed to store, or a full-body regex.
If you only need to route on a header and persist the bytes, do not parse the body at all. Store it as bytes and let the downstream consumer, which has a real CPU budget, do the parsing.
What belongs on the edge and what does not
After running this in production, the line is clearer than it was at the start.
Good on a Worker: terminating TLS close to the sender, rejecting junk, verifying signatures, persisting the payload, publishing to a queue, returning 200 fast. All I/O bound, all cheap, all latency-sensitive in a way that genuinely benefits from being near the caller.
Bad on a Worker: your business logic. Anything that needs a long-lived connection pool, a large dependency tree, heavy CPU, or the Node ecosystem. Not because it cannot be made to work, but because you would be fighting the platform for no benefit, and the whole point of putting the receiver at the edge is that the expensive part happens elsewhere on its own schedule.
That split is the same edge-ack-plus-async-deliver pattern described in the Chinese-language 為什麼你的 AI agent 需要 webhook relay, and it is the architecture AnyHook is built on rather than an argument we made up afterwards.
A deploy footgun worth knowing
If you run multiple environments in one wrangler.toml, wrangler deploy without --env production ships the default environment. Your production Worker keeps serving the previous code and nothing errors, because the deploy genuinely succeeded, just not where you meant.
We lost an afternoon to this. Secrets have the same shape: wrangler secret put NAME --env production, or you have set it somewhere that is not serving traffic.
Takeaway
A Worker is a very good webhook receiver as long as you accept what it is: an I/O-bound front door with no Node, no durability in waitUntil, and a CPU budget that punishes parsing you did not need.
Read bytes before anything parses them, cancel the stream when a payload is over your limit, order the cheap rejections before the expensive ones, and never return 200 for something you have not durably written down. Everything else belongs downstream, where it has time.