Skip to content
All tutorials
SampleIllustrative

Build a reliable webhook endpoint

Verify signatures, store deliveries durably, and make retries safe.

Topic
Connected operations
Format
Tutorial
Difficulty
Intermediate
Reading time
3 min read (estimate)
Steps
6
Working time
45 min to work through (estimate)

Learning outcome

Design authenticated receipt, durable storage, and repeatable processing.

Prerequisites

  • Familiarity with JavaScript and HTTP
  • Familiarity with SQL transactions
Contents, 6 steps

A sender will deliver the same webhook more than once. This sample walks through a receiver that treats that as normal. It is an authored example, not a tested recipe, and the endpoint it describes is not part of this website.

Step 1: Define the event contract

Write down the event before you write the handler. Name the type, the version, and the id the sender uses when it retries.

  1. Choose a stable event id that stays the same across retries.
  2. Put the type and version where both sides can see them.
  3. Decide which fields are required.
Snippet
{
  "id": "evt_123",
  "type": "order.placed",
  "version": 1,
  "occurredAt": "2026-04-04T15:00:00Z"
}

Checkpoint

You can point at one id that will not change if the sender tries again, and you can name the type and version.

Security and reliability

Do not use the arrival time as the event id. Two deliveries of one event would look like two events.

Step 2: Receive the raw body

Signature checks need the exact bytes the sender signed. Read the body once, as text or bytes, and parse it only after the check.

Snippet
export async function readRawBody(request: Request): Promise<string> {
  return request.text();
}

The example path /webhooks/orders belongs to the system you are designing. This site does not expose that route.

Checkpoint

The handler can show the raw body it will verify, and it has not parsed JSON yet.

Step 3: Verify the signature

Compare the sender's signature with one you compute from the raw body and a shared secret. Reject the request when they differ, and do not store a body you could not authenticate.

Snippet
import { createHmac, timingSafeEqual } from "node:crypto";

export function signaturesMatch(rawBody: string, secret: string, given: string): boolean {
  const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(given);
  return a.length === b.length && timingSafeEqual(a, b);
}

Checkpoint

A changed byte in the body fails the check, and a matching signature passes.

Step 4: Store before acknowledging

Insert the event in the same transaction that records "we have this id". If the insert says the id is already there, acknowledge again and stop. If the insert fails for another reason, return an error so the sender retries.

Snippet
insert into webhook_events (id, type, body)
values ($1, $2, $3)
on conflict (id) do nothing;

Checkpoint

A second delivery of the same id does not create a second row, and the sender only receives success after the row exists.

Step 5: Process safely in the background

The HTTP handler's job ends when the event is stored. A worker reads stored events and does the slow work. That work has to be safe to run twice, because the worker can crash after it starts and before it finishes.

  1. Read a stored event that is not marked done.
  2. Perform the action in a way that repeating it changes nothing the second time.
  3. Mark the event done in the same transaction as the action's own write, when you can.

Checkpoint

Restarting the worker does not send a second customer notice for an event that already completed.

Step 6: Exercise the failure paths

Try the cases you hope not to see.

  1. Send a body with a bad signature and confirm it is rejected and not stored.
  2. Send the same valid event twice and confirm one row.
  3. Stop the worker halfway and confirm the action still happens once.

Checkpoint

You have seen a rejected signature, a duplicate delivery, and a retried worker, and you can say what the system did in each case.

Wrap-up

The receiver is finished when those three failures behave on purpose. The next place to look is how the worker retries, and how you change the event contract without breaking senders that still use the old version.

Review

This guide is an authored example. It hasn't been verified as a complete, production-ready implementation.

Reaching the end of this page marks your reading position, not a verified completion.

SampleIllustrative

Version an event contract

Add a field without breaking senders that still emit the previous version.

Connected operations · Walkthrough · Foundational · 2 steps · 1 min read (estimate)

Read the guide

Contact

Have something worth building?

Share a few sentences about what you're trying to change. That's all it takes to start the conversation.