
Server Infrastructure
One endpoint that verifies signatures, answers in milliseconds, and guarantees delivery to everything downstream - deployed on a VPS with Coolify.
Node.js · Fastify · BullMQ · Redis · Coolify
View GitHub Repo: The service, the queue wiring, and the replay CLI.
Copy Docker Compose: Receiver and worker are separate services on purpose - a slow subscriber must never delay an acknowledgement.
Stripe, your CRM, and three SaaS tools all post webhooks directly into automation scenarios. When one of those scenarios is down, the event is gone - the sender retried twice into a 500 and gave up. Nobody finds out until the numbers disagree at month end.
Everything this blueprint calls. Check you have them before you start - the usual reason a build stalls halfway is a key that takes a day to get approved.
Webhook senders need to reach it. The smallest shared-vCPU instance handles a few hundred events a second.
Free tier: From about $4.50 a month
Gives the box a deploy pipeline, automatic TLS, and log access without building a platform first. Free and self-hosted.
Free tier: Free, self-hosted
Backs the queue. The compose file runs it locally; Upstash is the managed option if you would rather not.
Free tier: 10,000 commands a day
One A record. Coolify issues the certificate once it resolves.
Some links above are partner links and we earn a commission if you sign up through them. It costs you nothing extra, and it does not decide what goes in a blueprint - the self-hosted option is recommended wherever it is genuinely the better call.
Monthly, at the volumes this blueprint was tested against. Worth comparing against what the manual version of this work costs you in hours.
| Component | Cost | At what volume |
|---|---|---|
| Hetzner CX22 | $4.59/mo | Gateway, worker, and Redis together |
| Coolify | $0 | Self-hosted, no licence |
| Managed Redis | $0 - $10/mo | Only if you skip the local container |
The files
The repository runs locally in a minute. Getting it onto a box with TLS, log retention, and a replay path is the part worth having written down.
.env template with every secret the gateway reads, annotated
Coolify deploy notes, including the health check that avoids a restart loop
The replay CLI for draining the dead-letter queue after an outage
Signature verification for Stripe, GitHub, and HubSpot as working code
The decisions that matter, in the order you will meet them. Everything here is a thing that broke in testing before it was a rule.
Compute the HMAC over the raw bytes, compare in constant time, and reject anything that fails. Parsing JSON first means you are running your parser on unauthenticated input, which is exactly the wrong order.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(rawBody, header, secret) {
const expected = createHmac('sha256', secret).update(rawBody).digest();
const received = Buffer.from(header ?? '', 'hex');
// Length check first: timingSafeEqual throws on a mismatch rather than
// returning false, and an exception here is a 500 instead of a 401.
return (
received.length === expected.length && timingSafeEqual(received, expected)
);
}The handler verifies, enqueues, and returns 200. Nothing else. Every sender has a timeout measured in seconds and treats a slow response as a failure, which turns your slow subscriber into duplicate deliveries.
Fanning out inside a single job means one failing destination retries all of them. Separate jobs give each subscriber its own retry state, so a broken Slack webhook cannot cause duplicate CRM writes.
Send an idempotency key derived from the event id with every downstream call and have subscribers honour it. At-least-once delivery is the only guarantee a queue can offer, so the receiver has to be safe to repeat.
await queue.add(
'deliver',
{ subscriber, event },
{
jobId: `${subscriber.id}:${event.id}`, // dedupes a replayed event
attempts: 5,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: { age: 86_400 },
}
);After five attempts the job moves to the dead-letter queue with its full payload and error history, and an alert fires. A dead-letter queue nobody is told about is just a slower way of losing the event.
The difference between a demo and something you can leave running is entirely in this table. Every row is a failure the blueprint handles explicitly rather than hoping about.
| Failure | What happens |
|---|---|
| Invalid or missing signature | Rejected 401 before parsing. Counted per sender, so a rotated secret is visible within minutes. |
| Redis unavailable at receive time | Gateway returns 503 so the sender retries. Accepting an event you cannot persist is worse than refusing it. |
| Subscriber returns 5xx | Five attempts with jittered exponential backoff, then dead-lettered with the response body attached. |
| Duplicate event id | BullMQ jobId collision drops the second copy silently, which is the intended behaviour. |
A dead-lettered job, as stored.
{
"job_id": "crm-sync:evt_1QxR2mK8",
"subscriber": "crm-sync",
"event": { "id": "evt_1QxR2mK8", "type": "invoice.paid" },
"attempts": 5,
"first_failed_at": "2026-09-11T02:14:55Z",
"last_error": "POST https://crm.internal/hooks -> 502 Bad Gateway",
"replayable": true
}Done-For-You
Standing the gateway up is the straightforward half. Migrating live senders onto it without losing an event during the cutover is the half worth paying for.
A TechZapp sprint is a fixed-scope, fixed-price week. A senior engineer builds it in your environment, hands over the repository and the runbook, and you own every part of it afterwards. No platform of ours to keep paying for.
Deployed, TLS terminated, monitored, with alerting into your on-call
Senders migrated one at a time behind a dual-write, with reconciliation
Replay tooling and a written runbook for the next outage
Sprint pricing
One sprint, typically 5 working days
Fixed scope agreed before we start
Built in your environment, not ours
Repository, infrastructure, and runbook handed over
Two weeks of support after handover included
Scope This SprintTell us what you are integrating with. We reply within one business day, and we will say plainly if the blueprint above already covers it.