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.
Set the incomingSignatureType and incomingSignatureSecret fields on your webhook. Three modes are supported:
Custom (HMAC-SHA256)
Use this when sending events from your own application. Your app must include the following header on every request to the ingress:
Algorithm your app computes:
signature = HMAC-SHA256(key=incomingSignatureSecret, msg=rawBody)
header = "sha256=" + hex(signature)import crypto from "crypto";
async function sendToHostWebhook(
ingressUrl: string,
payload: object,
secret: string,
) {
const body = JSON.stringify(payload);
const hmac = crypto
.createHmac("sha256", secret)
.update(Buffer.from(body))
.digest("hex");
await fetch(ingressUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Webhook-Signature": `sha256=${hmac}`,
},
body,
});
}import { HttpService } from "@nestjs/axios";
import { lastValueFrom } from "rxjs";
import * as crypto from "crypto";
async sendToIngress(payload: object, ingressUrl: string, secret: string) {
const body = JSON.stringify(payload);
const hmac = crypto
.createHmac("sha256", secret)
.update(Buffer.from(body))
.digest("hex");
await lastValueFrom(
this.httpService.post(ingressUrl, body, {
headers: {
"Content-Type": "application/json",
"X-Webhook-Signature": `sha256=${hmac}`,
},
}),
);
}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.
Security notes
Replay protection
The t value in the X-HostWebhook-Signature header is the delivery time in milliseconds. Optionally reject requests older than 5 minutes:
const MAX_DRIFT_MS = 5 * 60 * 1000;
const ts = parseInt(parts["t"], 10);
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.
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>"
}