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

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.

Endpoint URL validation decision tree A submitted endpoint URL passes a scheme check, a DNS address check, and a URL shape check; failing any branch returns a distinct rejection, passing all three stores the subscription as pending. Cheapest checks first: no DNS lookup for a URL that fails on scheme. endpoint_url submitted 1. Scheme check https only, never plain http yes 2. Address check public unicast only yes 3. Shape check no userinfo, no fragment yes store as pending issue challenge token 422 scheme_not_https no DNS spent no 422 private_address log for review no 422 malformed_url credentials in host no
Distinct rejection codes per branch let a consumer fix the real problem instead of guessing at a generic 400.
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.

Challenge-response verification sequence The subscriber registers a URL, the provider posts a signed challenge to the candidate endpoint, the endpoint echoes the token, and the provider activates the subscription. Only code running at the URL can produce the right answer. Subscriber dashboard or API client Provider subscription API Consumer candidate endpoint POST /subscriptions signed challenge + token 200 with token echoed pending -> active 201 verified subscription Real events begin only after the transition at step 4.
Signing the challenge with the real signing secret lets the consumer prove their verification code works before any live payload depends on it.
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.

Verification token lifetime A timeline showing the pending window: the row is created, the challenge is delivered, retries are exhausted, and the token finally expires and the row is revoked. The pending window is bounded, so an unclaimed token cannot linger. row created status = pending challenge sent token in header retries done still unverified token expired row revoked t0 t0 + 2s t0 + 30m t0 + 24h verification window: no real events are delivered
Retries live inside the token's TTL; once it lapses the row is revoked rather than accumulating as a permanent pending record.

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

# 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

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.