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
  2. The ingest endpoint
  3. Ingest response codes
  4. Inbound signature verification
  5. Verifying Webhooker's signature
  6. Retries, circuit breaker & replay
  7. Meta / Facebook subscription checks

1. Quick start

  1. Create a source in the dashboard. You get a unique ingest URL of the form https://webhooker.eu/in/<token>.
  2. Paste that URL into your provider's webhook settings (Stripe, GitHub, Shopify, or anything that sends an HTTP POST).
  3. Add one or more destinations to the source — the URLs on your side that should receive each event.
  4. 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

StatusMeaning
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:

HeaderExampleMeaning
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.

Create your first source See pricing