Documentation
Webhooker docs
Last updated: 6 July 2026
Webhooker gives every source a unique ingest URL. It verifies the incoming signature, stores the event durably, then delivers it to your destinations with retries and a full per-attempt log. This page covers the two things you write code for: sending events to an ingest URL, and verifying Webhooker's signature when it delivers to your service.
Contents
1. Quick start
-
Create a source in the
dashboard. You get a unique ingest URL
of the form
https://webhooker.eu/in/<token>. - Paste that URL into your provider's webhook settings (Stripe, GitHub, Shopify, or anything that sends an HTTP POST).
- Add one or more destinations to the source — the URLs on your side that should receive each event.
- Optionally set a signing secret on a destination so Webhooker signs every delivery, then verify it in your handler (see below).
2. The ingest endpoint
Send events with an HTTP POST to your source's ingest URL. The request body
is stored verbatim; any content type is accepted. The maximum body size is
1 MB. A successful call returns 200 OK with the stored
event's ID.
curl -X POST https://webhooker.eu/in/YOUR_SOURCE_TOKEN \
-H "Content-Type: application/json" \
-d '{"event":"order.created","id":"evt_123"}'
# 200 OK
# {"id":"3f9c2a10-8b1e-4c7a-9f2d-1a2b3c4d5e6f"}
The returned id is the event's identifier in Webhooker — the same value
Webhooker later sends to your destinations in the X-Webhooker-Event-Id
header, so you can correlate an inbound event with its deliveries.
3. Ingest response codes
| Status | Meaning |
|---|---|
200 OK | Event accepted and stored durably. Body: {"id":"…"}. |
401 Unauthorized |
Inbound signature verification failed. The event is still stored (for audit) but
is not delivered. Body carries error.code = signature_invalid.
|
404 Not Found | The source token is unknown or the source was deleted. |
413 Payload Too Large | The request body exceeds the 1 MB limit. |
429 Too Many Requests |
The source exceeded your plan's ingest rate. A Retry-After header
tells you how many seconds to wait.
|
4. Inbound signature verification
You don't write any code for inbound verification — Webhooker does it for you. When you
configure a source's verification (for example Stripe's or GitHub's
HMAC-SHA256 / SHA1 scheme and secret) in the dashboard,
Webhooker checks every incoming request's signature before accepting it. Requests that
fail are rejected with 401 and never enter the delivery pipeline, so forged
payloads can't reach your services.
5. Verifying Webhooker's signature
When a destination has a signing secret (whsec_…), Webhooker adds these
headers to every delivery it makes to your endpoint:
| Header | Example | Meaning |
|---|---|---|
X-Webhooker-Signature | v1=9f86d0… | v1= followed by the hex HMAC-SHA256 of
"{timestamp}.{body}", keyed with your signing secret.
|
X-Webhooker-Timestamp | 1717500000 | Unix seconds at which the signature was computed. |
X-Webhooker-Event-Id | 3f9c2a10-… | The event's ID. Stable across retries — use it as an idempotency key. |
To verify: recompute HMAC-SHA256 over the string
timestamp + "." + rawBody using your signing secret, prefix it with
v1=, and compare it to the header with a constant-time comparison. Sign the
raw request bytes — if you parse the JSON first and re-serialize it, the
signature won't match. Rejecting deliveries whose timestamp is too old also blunts replay
attacks.
Node.js (Express)
const crypto = require("crypto");
const express = require("express");
const app = express();
// Capture the exact bytes Webhooker signed. Letting a JSON parser run first
// re-serializes the body and the signature will no longer match.
app.use(express.json({
verify: (req, res, buf) => { req.rawBody = buf; },
}));
const SIGNING_SECRET = process.env.WEBHOOKER_SIGNING_SECRET; // "whsec_..."
function isValid(req, toleranceSeconds) {
const received = req.get("X-Webhooker-Signature") || "";
const timestamp = req.get("X-Webhooker-Timestamp") || "";
// Reject stale deliveries to blunt replay attacks.
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!timestamp || age > toleranceSeconds) return false;
const hmac = crypto.createHmac("sha256", SIGNING_SECRET);
hmac.update(timestamp + ".");
hmac.update(req.rawBody);
const expected = "v1=" + hmac.digest("hex");
// Constant-time compare; timingSafeEqual throws on a length mismatch.
const a = Buffer.from(received);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post("/webhooks", (req, res) => {
if (!isValid(req, 300)) return res.status(401).send("bad signature");
// X-Webhooker-Event-Id is stable across retries: use it as an idempotency key.
const eventId = req.get("X-Webhooker-Event-Id");
// ... process the event, then acknowledge ...
res.sendStatus(200);
});
app.listen(3000); Python (Flask)
import hmac
import hashlib
import time
from flask import Flask, request
app = Flask(__name__)
SIGNING_SECRET = "whsec_..." # load this from an environment variable
def is_valid(req, tolerance_seconds=300):
received = req.headers.get("X-Webhooker-Signature", "")
timestamp = req.headers.get("X-Webhooker-Timestamp", "")
raw_body = req.get_data() # exact bytes, before any parsing
if not timestamp or abs(time.time() - int(timestamp)) > tolerance_seconds:
return False
signed = timestamp.encode() + b"." + raw_body
digest = hmac.new(SIGNING_SECRET.encode(), signed, hashlib.sha256).hexdigest()
expected = "v1=" + digest
return hmac.compare_digest(received, expected)
@app.post("/webhooks")
def webhooks():
if not is_valid(request):
return "bad signature", 401
# X-Webhooker-Event-Id is stable across retries: use it as an idempotency key.
event_id = request.headers.get("X-Webhooker-Event-Id")
# ... process the event, then acknowledge ...
return "", 200 Go (net/http)
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"strconv"
"time"
)
const signingSecret = "whsec_..." // load this from the environment
func isValid(r *http.Request, body []byte, tolerance time.Duration) bool {
received := r.Header.Get("X-Webhooker-Signature")
tsHeader := r.Header.Get("X-Webhooker-Timestamp")
ts, err := strconv.ParseInt(tsHeader, 10, 64)
if err != nil {
return false
}
// Reject stale deliveries to blunt replay attacks.
if time.Since(time.Unix(ts, 0)).Abs() > tolerance {
return false
}
mac := hmac.New(sha256.New, []byte(signingSecret))
mac.Write([]byte(tsHeader + "."))
mac.Write(body)
expected := "v1=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(received), []byte(expected))
}
func handler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
if !isValid(r, body, 5*time.Minute) {
w.WriteHeader(http.StatusUnauthorized)
return
}
// r.Header.Get("X-Webhooker-Event-Id") is a stable idempotency key.
w.WriteHeader(http.StatusOK)
} 6. Retries, circuit breaker & replay
If your endpoint doesn't return a 2xx, Webhooker retries with exponential
backoff. A per-destination circuit breaker stops hammering an endpoint that stays down,
and deliveries that exhaust their retries land in a dead-letter queue. From the dashboard
you can replay any single event or resend a whole batch once your service recovers. Your
handler should be idempotent: key on X-Webhooker-Event-Id so a retried or
replayed delivery is processed at most once.
7. Meta / Facebook subscription checks
Meta platforms (Facebook, Instagram, WhatsApp) verify a webhook subscription with a
GET handshake that expects the hub.challenge query parameter
echoed back. Webhooker answers that handshake on your ingest URL automatically, so you can
paste the same https://webhooker.eu/in/<token> URL into Meta's webhook
configuration without any extra setup.