Registering and validating webhook endpoints with a challenge handshake
A customer pastes https://hooks.acme.io/stripe-events into your dashboard and clicks save. At that instant you know exactly one thing: someone with a valid API key typed a string. You do not know that the host exists, that it belongs to that customer, that it is reachable from your egress, or that the person who typed it was authorised to redirect that account’s event stream somewhere new. Sending real payloads to an unproven URL is how providers leak customer data to a typo’d domain, and how an attacker with a stolen key exfiltrates an event stream without touching any other system. This guide implements the gate that closes that window: canonical URL validation followed by an echo-back challenge, sitting inside the broader webhook subscription management model. Once the endpoint is proven, the next question is which events it should actually receive, which is covered in event type filtering and subscription scoping.
The handshake itself is deliberately boring. You generate a random token, POST it to the candidate URL, and require the endpoint to return that exact token in its response body. Only code deployed at that URL can produce the right answer, so a successful echo proves control. Everything interesting is in the details: what you reject before you ever make the request, how long the token stays valid, and what happens when the URL changes later.
Prerequisites
- Runtime: Node.js 20+ with TypeScript 5.4+; the examples use Express 4.19 for the API surface.
- HTTP client:
undici6 (npm i undici) — its explicitconnectandheadersTimeoutoptions matter here, because the challenge request must not hang. - Validation:
zod3.23 for input parsing. - Storage: PostgreSQL 15+ with a
webhook_subscriptionstable carryingstatus,verification_token, andverification_expires_atcolumns. - A DNS resolver you can call directly (
node:dns/promises) so address checks happen before the request rather than being delegated to the HTTP stack. - An egress allowlist or proxy if your dispatcher runs inside a VPC with access to internal services — validation in code is necessary but not sufficient.
Step 1: Canonicalise and gate the submitted URL
Before anything touches the network, the string has to survive a fixed sequence of checks. Run them in cheapest-first order so obviously bad input never costs you a DNS lookup, and return a distinct error code per rejection reason — a consumer who gets a generic 400 will guess, and their next three attempts will be equally wrong.
The checks that matter in production: the scheme must be https (plain HTTP means signatures travel next to the payload they authenticate); the URL must carry no userinfo component, because https://user:pass@host/ smuggles credentials into a field you will later log; the hostname must resolve to a globally routable unicast address, excluding loopback, link-local 169.254.0.0/16, RFC 1918 space, unique-local IPv6, and IPv4-mapped IPv6 forms of all of the above; and the port must be one you are willing to dial.
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
export class EndpointRejected extends Error {
constructor(public readonly code: string, message: string) {
super(message);
}
}
const BLOCKED_V4 = [
{ net: 0x0a000000, mask: 0xff000000 }, // 10.0.0.0/8
{ net: 0xac100000, mask: 0xfff00000 }, // 172.16.0.0/12
{ net: 0xc0a80000, mask: 0xffff0000 }, // 192.168.0.0/16
{ net: 0x7f000000, mask: 0xff000000 }, // 127.0.0.0/8
{ net: 0xa9fe0000, mask: 0xffff0000 }, // 169.254.0.0/16
{ net: 0x64400000, mask: 0xffc00000 }, // 100.64.0.0/10 (CGNAT)
{ net: 0x00000000, mask: 0xff000000 }, // 0.0.0.0/8
];
function v4ToInt(addr: string): number {
return addr.split('.').reduce((acc, part) => (acc << 8) + Number(part), 0) >>> 0;
}
function isPublicAddress(addr: string): boolean {
const family = isIP(addr);
if (family === 4) {
const value = v4ToInt(addr);
return !BLOCKED_V4.some(({ net, mask }) => (value & mask) >>> 0 === net);
}
if (family === 6) {
const lower = addr.toLowerCase();
if (lower === '::1' || lower === '::') return false;
if (lower.startsWith('fe80') || lower.startsWith('fc') || lower.startsWith('fd')) return false;
// ::ffff:10.0.0.1 style mapped addresses must be re-checked as IPv4.
const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (mapped) return isPublicAddress(mapped[1]);
return true;
}
return false;
}
export async function canonicaliseEndpoint(raw: string): Promise<string> {
let url: URL;
try {
url = new URL(raw.trim());
} catch {
throw new EndpointRejected('malformed_url', 'Endpoint URL could not be parsed');
}
if (url.protocol !== 'https:') {
throw new EndpointRejected('scheme_not_https', 'Endpoint URL must use https');
}
if (url.username || url.password) {
throw new EndpointRejected('malformed_url', 'Endpoint URL must not contain credentials');
}
if (url.port && !['', '443', '8443'].includes(url.port)) {
throw new EndpointRejected('port_not_allowed', `Port ${url.port} is not accepted`);
}
const { address } = await lookup(url.hostname, { verbatim: true });
if (!isPublicAddress(address)) {
throw new EndpointRejected('private_address', 'Endpoint resolves to a non-public address');
}
url.hostname = url.hostname.toLowerCase();
url.hash = '';
if (url.port === '443') url.port = '';
if (url.pathname.length > 1 && url.pathname.endsWith('/')) {
url.pathname = url.pathname.slice(0, -1);
}
return url.toString();
}
Engineering Note: This resolve-then-fetch pattern still leaves a DNS rebinding gap — the name can resolve to a public address during validation and to 127.0.0.1 when the challenge request is made moments later. Close it by pinning the validated IP for the outbound request, or by routing all webhook egress through a proxy that enforces the same rules at connect time. The deeper treatment is in preventing SSRF in outbound webhook delivery.
Step 2: Persist the subscription as pending with a challenge token
Write the row before you make the network call. A subscription that exists in pending with a bounded expiry is inspectable, retryable, and safe: no dispatch query selects it, so no real event can reach the URL no matter what happens next.
The token must be single-use, high-entropy, and short-lived. Thirty-two random bytes hex-encoded is ample. Store it hashed if you want defence against a database read, though the value is bounded — the token grants nothing except the ability to complete a verification the requester already initiated.
import { randomBytes } from 'node:crypto';
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const VERIFICATION_TTL_HOURS = 24;
export interface PendingSubscription {
id: string;
endpointUrl: string;
challengeToken: string;
}
export async function createPendingSubscription(
tenantId: string,
rawUrl: string,
eventTypes: string[],
): Promise<PendingSubscription> {
const endpointUrl = await canonicaliseEndpoint(rawUrl);
const challengeToken = randomBytes(32).toString('hex');
const { rows } = await pool.query<{ id: string }>(
`INSERT INTO webhook_subscriptions
(tenant_id, endpoint_url, event_types, status,
verification_token, verification_expires_at)
VALUES ($1, $2, $3, 'pending', $4, now() + ($5 || ' hours')::interval)
ON CONFLICT (tenant_id, endpoint_url)
DO UPDATE SET verification_token = EXCLUDED.verification_token,
verification_expires_at = EXCLUDED.verification_expires_at
RETURNING id`,
[tenantId, endpointUrl, eventTypes, challengeToken, VERIFICATION_TTL_HOURS],
);
return { id: rows[0].id, endpointUrl, challengeToken };
}
Engineering Note: The ON CONFLICT clause makes re-submitting the same URL reissue a token rather than fail with a unique-violation. Consumers routinely click save twice while debugging; making the second click regenerate the challenge is the difference between a self-service fix and a support ticket.
Step 3: Send the challenge and require the token echoed back
Now make the request. Send a POST with a small JSON body identifying it as a verification probe, carry the token in a dedicated header, and sign the request with the subscription’s signing secret exactly as you will sign real deliveries. Signing the challenge means the consumer can implement one verification path, not two, and it lets them confirm their signature check works before any real event depends on it.
Require the token back in the response body. Accepting a bare 200 as proof is the classic mistake: catch-all reverse proxies, CDN error pages, and Kubernetes ingress defaults all return 200 for paths that route nowhere.
import { createHmac, timingSafeEqual } from 'node:crypto';
import { request } from 'undici';
const CHALLENGE_TIMEOUT_MS = 5_000;
export interface ChallengeResult {
ok: boolean;
statusCode: number;
reason?: string;
}
export async function sendChallenge(
endpointUrl: string,
subscriptionId: string,
challengeToken: string,
signingSecret: string,
): Promise<ChallengeResult> {
const timestamp = Math.floor(Date.now() / 1000);
const body = JSON.stringify({
type: 'endpoint.verification',
subscription_id: subscriptionId,
challenge: challengeToken,
sent_at: timestamp,
});
const signature = createHmac('sha256', signingSecret)
.update(`${timestamp}.${body}`)
.digest('hex');
let res;
try {
res = await request(endpointUrl, {
method: 'POST',
body,
headers: {
'content-type': 'application/json',
'user-agent': 'AcmeWebhooks/1.0 (+verification)',
'x-webhook-timestamp': String(timestamp),
'x-webhook-signature': `v1=${signature}`,
'x-webhook-challenge': challengeToken,
},
headersTimeout: CHALLENGE_TIMEOUT_MS,
bodyTimeout: CHALLENGE_TIMEOUT_MS,
maxRedirections: 0, // a redirect is not proof of control over the target
});
} catch (err) {
return { ok: false, statusCode: 0, reason: (err as Error).message };
}
if (res.statusCode !== 200) {
return { ok: false, statusCode: res.statusCode, reason: 'non_200_response' };
}
// Cap the body read: a hostile or broken endpoint may stream forever.
const text = (await res.body.text()).slice(0, 4096);
let echoed: string | undefined;
try {
echoed = JSON.parse(text)?.challenge;
} catch {
echoed = text.trim();
}
if (typeof echoed !== 'string' || echoed.length !== challengeToken.length) {
return { ok: false, statusCode: res.statusCode, reason: 'challenge_not_echoed' };
}
const match = timingSafeEqual(Buffer.from(echoed), Buffer.from(challengeToken));
return match
? { ok: true, statusCode: res.statusCode }
: { ok: false, statusCode: res.statusCode, reason: 'challenge_mismatch' };
}
Engineering Note: maxRedirections: 0 is deliberate. If the endpoint answers 302 to a third-party host and you follow it, you have verified control of the redirect target, not of the registered URL — and you have handed an attacker a way to point verification at somewhere they do control. Treat any 3xx as a failed challenge and tell the consumer to register the final URL.
Step 4: Promote the subscription to active
Use a guarded update so the transition can only happen from pending, and only while the token is unexpired. This one statement is the entire authorisation decision, and expressing it as a conditional UPDATE means a concurrent revoke or a second verification attempt cannot resurrect or double-activate the row.
export async function activateSubscription(
subscriptionId: string,
presentedToken: string,
): Promise<'activated' | 'already_active' | 'expired' | 'invalid'> {
const { rows } = await pool.query<{ status: string }>(
`UPDATE webhook_subscriptions
SET status = 'active',
verified_at = now(),
verification_token = NULL
WHERE id = $1
AND status = 'pending'
AND verification_token = $2
AND verification_expires_at > now()
RETURNING status`,
[subscriptionId, presentedToken],
);
if (rows.length === 1) {
await pool.query(
`INSERT INTO subscription_audit (subscription_id, action, payload)
VALUES ($1, 'verified', $2)`,
[subscriptionId, JSON.stringify({ method: 'challenge_echo' })],
);
return 'activated';
}
const { rows: current } = await pool.query<{ status: string; expired: boolean }>(
`SELECT status, verification_expires_at <= now() AS expired
FROM webhook_subscriptions WHERE id = $1`,
[subscriptionId],
);
if (current.length === 0) return 'invalid';
if (current[0].status === 'active') return 'already_active';
return current[0].expired ? 'expired' : 'invalid';
}
The token has a life of its own, and it needs to end. An unclaimed challenge should be retried a small number of times over a bounded window, then the row should be revoked rather than left pending forever — a pending row that no one ever verifies is indistinguishable from an abandoned integration.
Re-verification is the other half of this. Any update that changes endpoint_url must drop the subscription back to pending and reissue a token. Without that rule, an attacker holding a write-scoped key repoints a long-verified subscription at their own collector and inherits the entire event stream with no handshake at all.
Verification and testing
- Happy path: register a URL served by a test endpoint that echoes
x-webhook-challenge; assert the row transitionspendingtoactiveand thatverification_tokenis nulled. - Silent 200: point registration at a host that returns
200with an empty body; assert the result ischallenge_not_echoedand the row stayspending. This is the single most valuable test in the suite. - Redirect: serve a
302to a second host that would echo correctly; assert verification fails rather than following. - Private address: attempt registration of
https://localhost/hooksandhttps://[::1]/hooks; assertprivate_addressand confirm no outbound request was made. - Expiry: fast-forward
verification_expires_atand replay the activation call; assertexpiredand that the guardedUPDATEmatched zero rows.
# Minimal compliant consumer endpoint for the happy-path test.
cat > echo-endpoint.js <<'EOF'
const http = require('http');
http.createServer((req, res) => {
let raw = '';
req.on('data', (c) => (raw += c));
req.on('end', () => {
const body = JSON.parse(raw || '{}');
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ challenge: body.challenge }));
});
}).listen(8080);
EOF
node echo-endpoint.js &
# Register through the API and confirm the resulting status.
curl -s -X POST https://api.yourdomain.com/v1/subscriptions \
-H "Authorization: Bearer $API_KEY" \
-H 'content-type: application/json' \
-d '{"endpointUrl":"https://hooks.example.com/acme","eventTypes":["invoice.*"]}' \
| jq '.status' # expect "active" once the echo succeeds
Failure modes and gotchas
- Treating any
200as verification. Default ingress rules, CDN error pages, and framework catch-alls answer200for URLs that route to nothing. The endpoint must return the exact token; compare withtimingSafeEqualafter a length check, and reject empty or truncated bodies outright. - Following redirects during the challenge. A
3xxmoves the proof to a host the registrant may not control, and a consumer whose real handler sits behind a redirect will get a subscription that “verifies” but never receives events at the registered URL. SetmaxRedirections: 0and surface the redirect target in the error message. - Validating the hostname but connecting later. DNS can change between your
lookup()and the HTTP connect, which is a live rebinding hole. Pin the resolved address for the outbound connection, or force all webhook egress through a proxy that re-applies the address rules at connect time. - Leaving
endpoint_urlmutable without re-verification. An update that changes the URL must reset the row topending. Providers that skip this turn a write-scoped API key into a full event-stream redirect, and the change is invisible in delivery metrics because the volume never drops. - Unbounded pending rows. Without a TTL sweep, abandoned registrations accumulate and every one of them holds a unique-constraint slot on a URL the customer may later want to re-register properly. Expire and revoke on a schedule, and let a fresh
POSTstart a clean handshake. Once verified, the endpoint’s ongoing health becomes a separate concern — see auto-disabling failing webhook endpoints.
Frequently Asked Questions
The consumer's endpoint sits behind an Authorization header. How does it pass verification?
It cannot, and that is the correct outcome, because your dispatcher will not be holding their bearer token when real events start either. The webhook path has to be anonymous and authenticated by the signature instead, which is precisely what signing the challenge lets them prove. Where an edge platform forces authentication on every route, an egress IP allowlist scoped to that one path is the usual escape hatch.
Why POST a signed body instead of answering a GET with a query parameter?
A POST exercises the exact code path a real delivery will take: same method, same content type, same signature header, same body-parsing middleware and size limits. A GET echo proves only that the host answers something, and it leaves the consumer's parser and signature check untested until the first live event. The extra realism costs nothing and moves a whole class of failure forward to registration time.
Five seconds is tight for a cold-starting serverless consumer. Should the timeout be longer?
The per-request timeout is not the whole budget — the retries inside the token's lifetime are what absorb a cold start. Raising it to thirty seconds pins a connection and a worker slot per registration and still fails against a genuinely dead host, just more expensively. Keep the individual request short and let the retry schedule cover platform warm-up.
How many times should an unanswered challenge be retried before you stop?
Three or four attempts spread across the first half hour covers the realistic causes — a deploy mid-flight, a DNS record still propagating, a platform waking up — without turning registration into a job that runs all day. Reuse the same token on every attempt so a consumer who fixes their handler between tries succeeds without re-registering. After the last attempt the row simply waits out its expiry.
Should a healthy endpoint be re-challenged periodically after activation?
No. A scheduled re-challenge tests something that ordinary delivery already tests continuously, and it manufactures false failures whenever the probe lands inside a deploy window. Control of a URL only comes back into question when the URL changes, which is why re-verification is bound to a write on the endpoint column rather than to a clock.
The consumer's WAF blocks the challenge before their handler sees it. What do you give them?
Publish a stable set of egress ranges and a fixed user-agent, and repeat both in the rejection you return so the consumer can allowlist without a support round trip. Surface the observed status code and the first few hundred bytes of the response body too: a WAF block almost always announces itself in a body you would otherwise discard. Changing egress addresses without notice is what breaks these integrations months later.
Related
- Event type filtering and subscription scoping — deciding what a verified endpoint actually receives.
- Auto-disabling failing webhook endpoints — what happens when a verified endpoint stops answering.
- HMAC signature verification — the signing scheme the challenge request reuses.
- Webhook subscription management — the subscription resource model this handshake belongs to.