Verifying signatures
HostWebhook supports two signature mechanisms: outgoing signatures on every delivery it sends to your server, and incoming signature verification on events arriving at the ingress.
Outgoing signatures (HostWebhook → your server)
When a webhook has a signing secret configured, HostWebhook attaches an HMAC-SHA256 signature to every HTTP delivery it makes to your target URL.
Header format
tUnix timestamp in milliseconds at time of deliveryv1HMAC-SHA256 hex digest of`t`.`rawBody`— the timestamp, a dot, and the raw request body
crypto.timingSafeEqual in Node.js, hmac.compare_digest in Python). Regular string equality (===) is vulnerable to timing attacks.Verification algorithm
- Read the raw request body (before JSON parsing — you need the exact bytes)
- Read the
X-HostWebhook-Signatureheader - Parse
t=<timestamp>,v1=<signature> - Compute
HMAC-SHA256(key=signingSecret, msg="<t>.<rawBody>") - Compare the hex digest with
v1using constant-time comparison - Optionally reject if
tis older than 5 minutes (replay protection)
Code examples
Node.js
import crypto from "crypto";
function verifyHostWebhookSignature(
rawBody: string | Buffer,
headers: Record<string, string>,
secret: string,
): boolean {
const sigHeader = headers["x-hostwebhook-signature"];
if (!sigHeader) return false;
const parts = Object.fromEntries(
sigHeader.split(",").map((p) => p.trim().split("=", 2) as [string, string]),
);
const { t, v1 } = parts;
if (!t || !v1) return false;
const body = typeof rawBody === "string" ? rawBody : rawBody.toString("utf8");
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${body}`)
.digest("hex");
const expectedBuf = Buffer.from(expected, "hex");
const receivedBuf = Buffer.from(v1, "hex");
if (expectedBuf.length !== receivedBuf.length) return false;
return crypto.timingSafeEqual(expectedBuf, receivedBuf);
}
// Express — must preserve raw body
app.use(express.raw({ type: "application/json" }));
app.post("/webhooks", (req, res) => {
const valid = verifyHostWebhookSignature(
req.body,
req.headers as Record<string, string>,
process.env.SIGNING_SECRET!,
);
if (!valid) return res.status(401).json({ error: "Invalid signature" });
const payload = JSON.parse(req.body);
res.json({ received: true });
});NestJS
Enable raw body in main.ts, then read req.rawBody in your controller:
const app = await NestFactory.create(AppModule, { rawBody: true });import {
Controller, Post, Req, Headers,
RawBodyRequest, UnauthorizedException,
} from "@nestjs/common";
import { Request } from "express";
import crypto from "crypto";
@Controller("webhooks")
export class WebhooksController {
@Post()
handle(
@Req() req: RawBodyRequest<Request>,
@Headers() headers: Record<string, string>,
) {
const rawBody = req.rawBody;
if (!rawBody || !verify(rawBody, headers, process.env.SIGNING_SECRET!)) {
throw new UnauthorizedException("Invalid signature");
}
const payload = req.body;
return { received: true };
}
}
function verify(
rawBody: Buffer,
headers: Record<string, string>,
secret: string,
): boolean {
const sigHeader = headers["x-hostwebhook-signature"];
if (!sigHeader) return false;
const parts = Object.fromEntries(
sigHeader.split(",").map((p) => p.trim().split("=", 2) as [string, string]),
);
const { t, v1 } = parts;
if (!t || !v1) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody.toString("utf8")}`)
.digest("hex");
const expectedBuf = Buffer.from(expected, "hex");
const receivedBuf = Buffer.from(v1, "hex");
if (expectedBuf.length !== receivedBuf.length) return false;
return crypto.timingSafeEqual(expectedBuf, receivedBuf);
}Python
import hmac
import hashlib
def verify_hostwebhook_signature(raw_body: bytes, headers: dict, secret: str) -> bool:
sig_header = headers.get("x-hostwebhook-signature", "")
if not sig_header:
return False
parts = dict(p.strip().split("=", 1) for p in sig_header.split(",") if "=" in p)
t = parts.get("t")
v1 = parts.get("v1")
if not t or not v1:
return False
msg = f"{t}.{raw_body.decode('utf-8')}".encode()
expected = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
# Flask example
@app.route("/webhooks", methods=["POST"])
def handle_webhook():
raw_body = request.get_data()
if not verify_hostwebhook_signature(
raw_body, dict(request.headers), os.environ["SIGNING_SECRET"]
):
abort(401)
payload = request.get_json()
return {"received": True}Go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"strings"
)
func verifySignature(rawBody []byte, sigHeader, secret string) bool {
parts := make(map[string]string)
for _, p := range strings.Split(sigHeader, ",") {
kv := strings.SplitN(strings.TrimSpace(p), "=", 2)
if len(kv) == 2 {
parts[kv[0]] = kv[1]
}
}
t, v1 := parts["t"], parts["v1"]
if t == "" || v1 == "" {
return false
}
msg := fmt.Sprintf("%s.%s", t, string(rawBody))
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(msg))
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(v1))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
rawBody, _ := io.ReadAll(r.Body)
sigHeader := r.Header.Get("X-HostWebhook-Signature")
if !verifySignature(rawBody, sigHeader, os.Getenv("SIGNING_SECRET")) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"received":true}`))
}PHP
<?php
function verifyHostWebhookSignature(string $rawBody, array $headers, string $secret): bool {
$sigHeader = $headers["x-hostwebhook-signature"]
?? $headers["X-HostWebhook-Signature"]
?? "";
if (!$sigHeader) return false;
$parts = [];
foreach (explode(",", $sigHeader) as $part) {
[$k, $v] = explode("=", trim($part), 2);
$parts[$k] = $v;
}
if (empty($parts["t"]) || empty($parts["v1"])) return false;
$msg = $parts["t"] . "." . $rawBody;
$expected = hash_hmac("sha256", $msg, $secret);
return hash_equals($expected, $parts["v1"]);
}Incoming signature verification (your app → HostWebhook ingress)
When you configure Incoming Signature Verification on a webhook, HostWebhook validates the signature of every event arriving at the ingress (POST /api/in/:token). Requests with an invalid or missing signature are rejected with 401 Unauthorized. The attempt is still recorded as an event with status SIGNATURE_INVALID, so a rejected request is visible in the events list instead of disappearing.
hostwebhook and custom types the ingress requires t=<timestamp>,v1=<hex> and rejects the header before computing anything at all if it does not start with t=. A bare sha256=<hex> — the shape most webhook guides show — always returns 401, however correct the HMAC inside it is.Set incomingSignatureType on the webhook and give it a secret — pasted into incomingSignatureSecret, or picked from your workspace secrets (see Referencing a Signing Secret). Six modes are supported:
| Type | Header read | Value it expects |
|---|---|---|
| hostwebhook | X-HostWebhook-Signature, X-Webhook-Signature or X-Signature | t=<ts>,v1=<hex> — HMAC-SHA256 over "<t>.<rawBody>", 5-minute window |
| custom | The same three headers | Identical to hostwebhook — the two types run the same verifier |
| stripe | Stripe-Signature | t=<ts>,v1=<hex> — Stripe computes it; forward the header untouched |
| github | X-Hub-Signature-256 | sha256=<hex> — HMAC-SHA256 over the raw body, no timestamp |
| shopify | X-Shopify-Hmac-Sha256 | HMAC-SHA256 over the raw body in base64, bare — no prefix |
| static | Your incomingSignatureHeader, or Authorization / X-Webhook-Secret / X-API-Key | The shared secret itself. No HMAC, so the body is not authenticated |
incomingSignatureType switches the protection off in the safe direction, by rejecting everything.HostWebhook (HMAC-SHA256)
The mode to use when the sender is your own application or another HostWebhook webhook. It is the same scheme HostWebhook uses on the way out, so a webhook can feed another one with no translation in between.
Algorithm your app computes:
t = current unix time — milliseconds or seconds, both accepted
v1 = hex( HMAC-SHA256(key = signing secret, msg = "<t>.<rawBody>") )
header = "t=" + t + ",v1=" + v1What the ingress checks, in this order:
- The first header present out of
x-hostwebhook-signature,x-webhook-signature,x-signature— in that order of preference. Header names are case-insensitive; send whichever one you like, but send only one. - It starts with
t=. The timestamp must be the first element:v1=...,t=...is rejected outright. tis within 300 seconds of the ingress clock, in either direction. A timestamp in the future by more than five minutes fails too.v1equals the HMAC of"<t>.<rawBody>"— the timestamp exactly as it appears in the header, a literal dot, and the raw bytes of the body — compared in constant time.
t=123, v1=abc does not parse), and v1 must be lowercase hex, which is what every standard library returns by default.import crypto from "crypto";
async function sendToHostWebhook(
ingressUrl: string,
payload: object,
secret: string,
) {
// 1. Serialise ONCE. This exact string is what gets signed AND sent.
const rawBody = JSON.stringify(payload);
// 2. Timestamp + HMAC over "<t>.<rawBody>".
// Date.now() is milliseconds; seconds work too.
const t = Date.now();
const v1 = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
// 3. Send the same string — never the object, or fetch re-serialises it.
const res = await fetch(ingressUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-HostWebhook-Signature": `t=${t},v1=${v1}`,
},
body: rawBody,
});
if (res.status === 401) {
throw new Error("Ingress rejected the signature — check secret, format and clock");
}
return res.json();
}import { HttpService } from "@nestjs/axios";
import { lastValueFrom } from "rxjs";
import * as crypto from "crypto";
async sendToIngress(payload: object, ingressUrl: string, secret: string) {
const rawBody = JSON.stringify(payload);
const t = Date.now();
const v1 = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
await lastValueFrom(
// Post the string, not the object: axios would serialise an object again.
this.httpService.post(ingressUrl, rawBody, {
headers: {
"Content-Type": "application/json",
"X-HostWebhook-Signature": `t=${t},v1=${v1}`,
},
}),
);
}SECRET="mysupersecret"
URL="https://api.hostwebhook.com/api/in/abc123def456"
BODY='{"event":"order.created","orderId":1234}'
T=$(date +%s)
V1=$(printf '%s' "$T.$BODY" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)
# --data-raw sends BODY byte for byte: no @file expansion, no newline stripping.
curl -sS -X POST "$URL" \
-H "Content-Type: application/json" \
-H "X-HostWebhook-Signature: t=$T,v1=$V1" \
--data-raw "$BODY"Signing a request from Postman
Postman is where most people try this first. Compute the signature in a Pre-request Script, leave both the body and the header value in environment variables, and reference them with {{...}}. Sending the body through a variable is what guarantees the signed bytes and the sent bytes are the same string.
// The CryptoJS global is deprecated — require it explicitly.
const CryptoJS = require('crypto-js');
// 1. Serialise the body ONCE.
const rawBody = JSON.stringify({
event: "order.created",
orderId: 1234,
});
// 2. Sign "<t>.<rawBody>".
const t = Date.now();
const secret = pm.environment.get("HOSTWEBHOOK_INGRESS_SIGNING_SECRET");
const v1 = CryptoJS.HmacSHA256(`${t}.${rawBody}`, secret)
.toString(CryptoJS.enc.Hex);
// 3. Leave both where the request can read them.
pm.environment.set("hwBody", rawBody);
pm.environment.set("hwSignature", `t=${t},v1=${v1}`);Then, in the request itself — Body → raw → JSON is the single line {{hwBody}}, and the Headers tab carries one extra row:
| Key | Value |
|---|---|
| X-HostWebhook-Signature | {{hwSignature}} |
| Content-Type | application/json |
pm.request.headers.upsert(...). Setting the header from the script looks tidier, but in some Postman versions the mutation never reaches the request that goes out, and you get a 401 with a script that reads as correct. An environment variable plus a header row declared in the Headers tab is the route that works everywhere.Custom (HMAC-SHA256)
custom and hostwebhook are verified by the same code: same three accepted headers, same t=<ts>,v1=<hex> format, same 300-second window. Pick custom when the sender is a third party that already signs this way — typically under X-Webhook-Signature or X-Signature — and hostwebhook when the sender is you or another HostWebhook webhook. Everything in the section above applies unchanged.
Stripe-compatible
Use this when forwarding events you receive from Stripe. HostWebhook verifies Stripe's native stripe-signature header. Forward the header as-is and configure the same Stripe webhook secret in both your application and the webhook's incomingSignatureSecret.
GitHub-compatible
Use this when forwarding GitHub webhook events. HostWebhook verifies GitHub's native x-hub-signature-256 header. Configure the same GitHub secret in both GitHub's webhook settings and the webhook's incomingSignatureSecret.
hostwebhook instead if you control the sender and want that protection.Shopify-compatible
Use this when forwarding Shopify webhook events. HostWebhook verifies Shopify's native x-shopify-hmac-sha256 header, which carries the HMAC of the raw body in base64 — no sha256= prefix, no timestamp. Which secret depends on who created the webhook in Shopify: one created in the admin, under Settings → Notifications → Webhooks, is signed with the key that page shows; one created by an app, with that app's client secret.
Static (shared secret, no HMAC)
For senders that cannot compute an HMAC at all. The ingress compares a header value against the secret in constant time — nothing is hashed and the body is not authenticated, so anyone who captures one request has everything needed to forge the next.
If incomingSignatureHeader is set, that header is the only one checked, and its value must equal the secret exactly:
incomingSignatureHeader: X-Partner-Key
X-Partner-Key: <the secret, verbatim>With no custom header configured, three defaults are tried instead, and any one of them matching is enough:
Authorization: Bearer <secret>— theBearerprefix is stripped before comparing; a bare value also worksX-Webhook-Secret: <secret>X-API-Key: <secret>
X-API-Key starts getting 401 the moment incomingSignatureHeader is filled in.Referencing a Signing Secret instead of pasting one
A pasted secret lives in two places at once: the sender's configuration and this webhook. Rotating it means two edits, and between them every delivery fails.
When the sender is your own application or another HostWebhook webhook, mint the secret in Settings → Signing Secrets and reference it from the webhook panel's signing secret picker instead of pasting a copy. Both ends then point at the same document: rotating is one write, and there is no second copy to drift out of sync.
When the ingress answers 401
The response body says Invalid webhook signature and nothing else: it does not say which of the checks failed. Work down this list:
- Is the header
t=<ts>,v1=<hex>, witht=first and no spaces? Asha256=prefix never verifies. - Are the signed bytes the sent bytes? Log both and compare lengths before comparing content.
- Is the sender's clock within five minutes of real time?
- Is the secret the same on both ends — and, if referenced, still active and not expired?
- Does
incomingSignatureTypematch the scheme actually being sent? A GitHub-style header against typehostwebhookfails at step one.
The rejected attempt is in the events list with status SIGNATURE_INVALID, including the raw body received — which is the fastest way to check what actually arrived on the wire.
Security notes
Replay protection
Both directions carry the same t, and the two ends read it differently. Which is which matters:
- Outgoing — what HostWebhook signs on the way to your server is always milliseconds. Every signer calls
Date.now(), so the value inX-HostWebhook-Signatureis a millisecond unix timestamp. - Incoming — what the ingress accepts from you is either scale. Anything above
1e11is read as milliseconds, anything below as seconds: that boundary is seconds until the year 5138 and milliseconds since 1973, so no real date is ambiguous. Send whichever your language hands you.
The tolerance is 300 seconds on both sides and it is absolute — a clock running more than five minutes ahead fails exactly like one running behind. On your own server, verifying a delivery from HostWebhook, the check reads:
const MAX_DRIFT_MS = 5 * 60 * 1000;
const ts = parseInt(parts["t"], 10); // outgoing: always milliseconds
if (Math.abs(Date.now() - ts) > MAX_DRIFT_MS) {
return false; // Reject stale delivery
}Rotating secrets
Signing secrets and ingress tokens can be rotated from the webhook detail page in the dashboard without recreating the webhook. After rotation, the old secret is immediately invalidated.
On the incoming side that window disappears if both ends reference the same Signing Secret instead of holding a copy each: rotation becomes one write, and there is no moment where the two copies disagree.
Environment variables
Recommended environment variable names for applications that integrate with HostWebhook:
| Variable | Description |
|---|---|
| HOSTWEBHOOK_INGRESS_URL | Full URL of the ingress — https://<host>/api/in/<token> |
| HOSTWEBHOOK_SIGNING_SECRET | Secret for verifying X-HostWebhook-Signature on deliveries (starts with whsec_) |
| HOSTWEBHOOK_INGRESS_SIGNING_SECRET | Secret your app uses to sign requests it sends to the ingress |
HOSTWEBHOOK_INGRESS_URL=https://api.hostwebhook.com/api/in/abc123def456
HOSTWEBHOOK_SIGNING_SECRET=whsec_a1b2c3d4e5f6...
HOSTWEBHOOK_INGRESS_SIGNING_SECRET=mysupersecretTesting
Use the Send Test Event button in the webhook detail page. Enable Include Signature to send a test delivery with a valid X-HostWebhook-Signature header, so you can verify your server-side verification logic end-to-end.
The test event payload defaults to:
{
"event": "test",
"message": "This is a test event from HostWebhook",
"timestamp": "<iso_timestamp>"
}