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.

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:

X-Webhook-Signature: sha256=<hex_hmac>

Algorithm your app computes:

signature = HMAC-SHA256(key=incomingSignatureSecret, msg=rawBody)
header    = "sha256=" + hex(signature)
typescriptsend-to-ingress.ts
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,
  });
}
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 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.

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.

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>

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.

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

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