Receiving webhooks
How to set up your server to handle deliveries from HostWebhook and respond correctly.
Your webhook handler
HostWebhook delivers events as HTTP POST requests to your target URL. Your handler must:
- Accept
POSTrequests - Read the raw request body before parsing (needed for signature verification)
- Respond with a
2xxstatus within 30 seconds - Return a non-2xx or nothing to trigger a retry
⚠Always respond quickly with
202 and process the event asynchronously in the background. If your handler takes more than 30 seconds it will be treated as a timeout and retried.Request headers
HostWebhook adds the following headers to every delivery:
| Header | Description |
|---|---|
| X-HostWebhook-Event-Id | Unique event ID — use for idempotency |
| X-HostWebhook-Attempt | Delivery attempt number (1 = first try, 2 = first retry, …) |
| X-HostWebhook-Signature | t=<ms>,v1=<hmac-sha256> — authenticate the payload |
| Content-Type | Always application/json |
ℹOriginal headers from the inbound request (e.g.
Stripe-Signature, X-GitHub-Event) are forwarded alongside the above headers. This lets you also verify the upstream provider's signature if needed.Idempotency
Because HostWebhook retries failed deliveries, your handler may receive the same event more than once. Use X-HostWebhook-Event-Id to deduplicate:
typescriptwebhook-handler.ts
app.post("/webhooks", async (req, res) => {
const eventId = req.headers["x-hostwebhook-event-id"] as string;
const alreadyProcessed = await db.processedEvents.findOne({ eventId });
if (alreadyProcessed) {
return res.status(200).json({ status: "duplicate" });
}
await handleEvent(req.body);
await db.processedEvents.insertOne({ eventId, processedAt: new Date() });
res.status(202).json({ status: "ok" });
});Handler examples
Node.js (Express)
typescriptsrc/webhooks/handler.ts
import express from "express";
import crypto from "crypto";
const app = express();
app.post(
"/webhooks",
express.raw({ type: "application/json" }), // preserve raw body for verification
(req, res) => {
const sig = req.headers["x-hostwebhook-signature"] as string;
const secret = process.env.SIGNING_SECRET!;
const body = req.body.toString("utf8");
// Parse "t=<timestamp>,v1=<hmac>"
const parts = Object.fromEntries(
sig.split(",").map((p) => p.trim().split("=", 2) as [string, string]),
);
const { t, v1 } = parts;
if (!t || !v1) return res.status(401).end();
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${body}`)
.digest("hex");
const ok = crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(v1, "hex"),
);
if (!ok) return res.status(401).json({ error: "Invalid signature" });
const payload = JSON.parse(body);
res.status(202).json({ received: true });
processAsync(payload).catch(console.error);
},
);NestJS
typescriptmain.ts
// Enable raw body capture — required for signature verification
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; // Buffer — exact bytes as received
if (!rawBody || !verifySignature(rawBody, headers, process.env.SIGNING_SECRET!)) {
throw new UnauthorizedException("Invalid signature");
}
const payload = req.body;
return { received: true };
}
}
function verifySignature(
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 (FastAPI)
pythonmain.py
import hmac, hashlib, os
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
SIGNING_SECRET = os.environ["SIGNING_SECRET"]
@app.post("/webhooks")
async def webhook_handler(request: Request):
body = await request.body()
sig = request.headers.get("x-hostwebhook-signature", "")
# Parse "t=<timestamp>,v1=<hmac>"
parts = dict(p.strip().split("=", 1) for p in sig.split(",") if "=" in p)
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1:
raise HTTPException(status_code=401, detail="Missing signature")
msg = f"{t}.{body.decode('utf-8')}".encode("utf-8")
expected = hmac.new(SIGNING_SECRET.encode(), msg, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, v1):
raise HTTPException(status_code=401, detail="Invalid signature")
return {"received": True}PHP
phpwebhook.php
<?php
$secret = getenv('SIGNING_SECRET');
$body = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_HOSTWEBHOOK_SIGNATURE'] ?? '';
$parts = [];
foreach (explode(',', $sig) as $p) {
[$k, $v] = explode('=', trim($p), 2);
$parts[$k] = $v;
}
$msg = ($parts['t'] ?? '') . '.' . $body;
$expected = hash_hmac('sha256', $msg, $secret);
if (!hash_equals($expected, $parts['v1'] ?? '')) {
http_response_code(401);
exit('Invalid signature');
}
$payload = json_decode($body, true);
http_response_code(202);
echo json_encode(['received' => true]);Timeouts & failures
A delivery is considered failed if your server:
- Takes longer than 30 seconds to respond
- Returns a status code outside the
2xxrange - Closes the connection unexpectedly
- Is unreachable (DNS failure, connection refused)
Failed deliveries are retried automatically. See Events & retries for the full retry schedule.