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

X-HostWebhook-Signature: t=1748000000000,v1=a3f9c2...
  • tUnix timestamp in milliseconds at time of delivery
  • v1HMAC-SHA256 hex digest of `t`.`rawBody` — the timestamp, a dot, and the raw request body
⚠Always use the raw body (before any JSON parsing). Parsing and re-serializing JSON can change whitespace and key order, producing a different signature.
⚠Always use a constant-time comparison (e.g. crypto.timingSafeEqual in Node.js, hmac.compare_digest in Python). Regular string equality (===) is vulnerable to timing attacks.

Verification algorithm

  1. Read the raw request body (before JSON parsing — you need the exact bytes)
  2. Read the X-HostWebhook-Signature header
  3. Parse t=<timestamp>,v1=<signature>
  4. Compute HMAC-SHA256(key=signingSecret, msg="<t>.<rawBody>")
  5. Compare the hex digest with v1 using constant-time comparison
  6. Optionally reject if t is older than 5 minutes (replay protection)

Code examples

Node.js

typescriptverify.ts
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:

typescriptmain.ts
const app = await NestFactory.create(AppModule, { rawBody: true });
typescriptwebhooks.controller.ts
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

pythonverify.py
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

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

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

⚠The signature must carry a timestamp. For the 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:

TypeHeader readValue it expects
hostwebhookX-HostWebhook-Signature, X-Webhook-Signature or X-Signaturet=<ts>,v1=<hex> — HMAC-SHA256 over "<t>.<rawBody>", 5-minute window
customThe same three headersIdentical to hostwebhook — the two types run the same verifier
stripeStripe-Signaturet=<ts>,v1=<hex> — Stripe computes it; forward the header untouched
githubX-Hub-Signature-256sha256=<hex> — HMAC-SHA256 over the raw body, no timestamp
shopifyX-Shopify-Hmac-Sha256HMAC-SHA256 over the raw body in base64, bare — no prefix
staticYour incomingSignatureHeader, or Authorization / X-Webhook-Secret / X-API-KeyThe shared secret itself. No HMAC, so the body is not authenticated
ℹAn unrecognised type is rejected, not waved through: verification fails closed. So is a webhook whose secret cannot be read — a typo in 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.

X-HostWebhook-Signature: t=1748000000000,v1=a3f9c2...

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=" + v1

What the ingress checks, in this order:

  1. 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.
  2. It starts with t=. The timestamp must be the first element: v1=...,t=... is rejected outright.
  3. t is within 300 seconds of the ingress clock, in either direction. A timestamp in the future by more than five minutes fails too.
  4. v1 equals 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.
⚠Sign the exact bytes you send. Serialise the body once into a variable, sign that variable, and send that same variable. If you hand an object to your HTTP client and let it serialise the body itself, the bytes on the wire can differ from the bytes you signed — a reordered key or one space is enough — and the 401 that follows looks exactly like a wrong secret.
ℹTwo smaller details that produce the same 401: the header takes no whitespace around the comma (t=123, v1=abc does not parse), and v1 must be lowercase hex, which is what every standard library returns by default.
typescriptsend-to-ingress.ts
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();
}
typescriptNestJS — using HttpService
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}`,
      },
    }),
  );
}
bashcurl
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.

javascriptPre-request Script
// 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:

KeyValue
X-HostWebhook-Signature{{hwSignature}}
Content-Typeapplication/json
⚠Do not rely on 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.

stripe-signature: t=1748000000,v1=<hex_hmac>
ℹNo changes needed on your side — Stripe computes and sends the header. Just forward the request to the HostWebhook ingress URL and make sure the same webhook secret is configured in both places. The same 300-second replay window applies, so a Stripe event you buffered for longer than that will no longer verify.

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.

x-hub-signature-256: sha256=<hex_hmac>
ℹThis is the one mode with no timestamp: the HMAC covers the raw body alone, so there is no replay window to fall outside of. Use 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.

x-shopify-hmac-sha256: <base64_hmac>

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> — the Bearer  prefix is stripped before comparing; a bare value also works
  • X-Webhook-Secret: <secret>
  • X-API-Key: <secret>
⚠Configuring a custom header disables the three defaults. A sender that used to authenticate with 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.

✦A referenced secret and a pasted one are mutually exclusive — choosing one clears the other, so “which secret is in use” always has a single answer. A referenced secret that has expired, been deactivated, or belongs to another organization resolves to nothing, and every incoming signature is rejected until it is replaced.

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:

  1. Is the header t=<ts>,v1=<hex>, with t= first and no spaces? A sha256= prefix never verifies.
  2. Are the signed bytes the sent bytes? Log both and compare lengths before comparing content.
  3. Is the sender's clock within five minutes of real time?
  4. Is the secret the same on both ends — and, if referenced, still active and not expired?
  5. Does incomingSignatureType match the scheme actually being sent? A GitHub-style header against type hostwebhook fails 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 in X-HostWebhook-Signature is a millisecond unix timestamp.
  • Incoming — what the ingress accepts from you is either scale. Anything above 1e11 is 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.

✦Update your server's environment variable before rotating the secret in the dashboard, or rotate during a maintenance window to avoid dropped deliveries.

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:

VariableDescription
HOSTWEBHOOK_INGRESS_URLFull URL of the ingress — https://<host>/api/in/<token>
HOSTWEBHOOK_SIGNING_SECRETSecret for verifying X-HostWebhook-Signature on deliveries (starts with whsec_)
HOSTWEBHOOK_INGRESS_SIGNING_SECRETSecret your app uses to sign requests it sends to the ingress
bash.env
HOSTWEBHOOK_INGRESS_URL=https://api.hostwebhook.com/api/in/abc123def456
HOSTWEBHOOK_SIGNING_SECRET=whsec_a1b2c3d4e5f6...
HOSTWEBHOOK_INGRESS_SIGNING_SECRET=mysupersecret

Testing

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>"
}