Webhook Relay
Receive, verify, de-duplicate and retry webhooks so no payment event is lost or handled twice.
What it does
A small Go service that sits between Paystack, Stripe or Flutterwave and your app. It checks the provider's signature, drops duplicates, stores every event, answers the provider straight away, and delivers to your app with retries until your app says it got it.
The problem
Payment webhooks arrive late, twice, or out of order, and your server is sometimes down when they arrive. Most apps handle this badly, which is how customers get charged twice or never get what they paid for.
What you get



How to use it
Run the relay, point your payment provider at it instead of your app, and check the relay's signature in your app. Two changes, whichever provider you use.
1. Run it
git clone https://github.com/Jacksonoseikojo1122/webhook-relay.git
cd webhook-relay
cp relay.example.json relay.json
RELAY_ADMIN_TOKEN=change-me go run ./cmd/relay # http://localhost:80802. Add a source in relay.json
{
"sources": [
{ "name": "paystack-live", "provider": "paystack",
"secret": "$PAYSTACK_SECRET_KEY",
"destination": "https://api.your-app.com/webhooks/paystack",
"destination_secret": "$RELAY_TO_APP_SECRET" }
]
}3. Set the webhook URL in Paystack
https://<your-relay>/in/paystack-live4. Verify deliveries in your app (Node.js)
app.post("/webhooks/paystack", express.raw({ type: "*/*" }), async (req, res) => {
const ts = req.get("x-relay-timestamp") ?? "";
const want = "v1=" + crypto.createHmac("sha256", process.env.RELAY_TO_APP_SECRET!)
.update(`${ts}.${req.body}`).digest("hex");
const sig = req.get("x-relay-signature") ?? "";
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300 || sig.length !== want.length ||
!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want))) {
return res.status(401).send("bad signature");
}
// Same ID on every retry: saving it once makes the handler idempotent.
await saveOnce(req.get("x-relay-event-id")!, req.body);
res.sendStatus(200);
});5. Or in Python (FastAPI)
def verify_relay(raw: bytes, ts: str, sig: str) -> bool:
if abs(time.time() - int(ts)) > 300:
return False
want = "v1=" + hmac.new(SECRET, f"{ts}.".encode() + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(sig, want)Documentation
- Getting started Run it locally and watch a failed delivery get retried
- Integration guide Paystack, Stripe and Flutterwave setup; verifying in Node, Python, PHP and Go
- How it works Receiving, claiming, backoff, replay and storage
- Operations Docker, Render free tier, Kubernetes, alerts and tracing
- OpenAPI spec Every endpoint, for Postman, Insomnia or code generators
Features
- Signature checks for Paystack, Stripe and Flutterwave, plus a generic HMAC mode
- Duplicate detection by provider event ID, enforced by the database
- Retries with backoff (10s, 30s, 2m, 10m, 30m, then hourly) and one-click replay
- One signature format for your app, with a stable event ID to use as an idempotency key
- Dashboard with every attempt, status code, error and payload
- Prometheus metrics and OpenTelemetry traces
- SQLite for one box, Postgres for production and several replicas
What building it covers
- Go services
- At-least-once delivery
- Database-backed work queues
- Tracing and observability
Stack
Questions
Which providers does Webhook Relay support?
Paystack, Stripe and Flutterwave have their signatures checked natively. Anything else can use the generic mode, which accepts an optional HMAC-SHA256 signature in X-Signature.
What happens if my app is down when a webhook arrives?
The relay has already stored the event and told the provider it was received. It retries your app after 10 seconds, 30 seconds, 2 minutes, 10 minutes, 30 minutes, then every hour, up to 12 attempts by default. After that the event is marked dead and you can replay it from the dashboard or the API.
How does it stop duplicate webhooks?
Each event is keyed by the provider's own event ID, and the database refuses a second copy for the same source. Duplicates get a 200 so the provider stops resending, but they aren't delivered to your app again.
Can my app still receive the same event twice?
Yes, rarely. Delivery is at least once: if your app saved an event but the 200 never reached the relay, it will try again. Every delivery carries the same X-Relay-Event-Id, so store that and ignore repeats.
How does my app know a delivery really came from the relay?
Each delivery is signed with X-Relay-Signature, an HMAC-SHA256 of the timestamp and body using a secret only your app and the relay know. It's the same format whichever provider the event came from.
What do I need to host it?
One small Go binary or Docker image. SQLite works for a single machine; use Postgres for production or several replicas. It runs on Render's free tier with a free Postgres from Render or Neon.
Is it free?
Yes. It's open source under the MIT licence.