Back to Blog
sdkdeveloper-experiencewebhookssecuritytypescript

Designing a Great Webhook SDK: Verification, Typing, and Developer Ergonomics

Most webhook SDKs are an afterthought — a thin wrapper around an HTTP client that leaves developers to figure out signature verification, payload typing, and idempotency on their own. Here's what a first-class webhook SDK actually looks like.

F
Finn Eriksson
Payments Engineer
April 26, 2026
10 min read

I've integrated with a lot of webhook providers. Stripe, GitHub, Shopify, PagerDuty, Twilio — all of them have different SDKs, different verification approaches, and different opinions about how much they want to hand-hold you through the integration. The gap between the best and worst developer experience in this space is enormous, and it comes down to a handful of concrete design decisions.

If you're building a SaaS product that sends webhooks to your customers, or if you're building the infrastructure layer that powers other companies' webhooks, the SDK you ship shapes how confidently developers integrate with you. A great SDK turns a two-day integration into an afternoon. A bad one generates support tickets indefinitely.

This post covers what a first-class webhook SDK actually looks like — from verification to payload types to testing helpers.


Start with Signature Verification, Not HTTP

The most common mistake in webhook SDK design is treating the SDK as a convenience wrapper around making HTTP requests. Webhook consumption is not primarily about HTTP — it's about receiving authenticated, typed events and processing them reliably.

The first thing your SDK should do is make signature verification impossible to skip.

typescript
import { WebhookClient } from "@yoursaas/webhooks";

const client = new WebhookClient({ secret: process.env.WEBHOOK_SECRET });

// Express handler — verification happens inside `constructEvent`
app.post("/webhooks", express.raw({ type: "application/json" }), (req, res) => {
  let event;
  try {
    event = client.constructEvent(req.body, req.headers["x-signature"]);
  } catch (err) {
    // Signature invalid or timestamp too old
    return res.status(400).send(`Webhook verification failed: ${err.message}`);
  }

  // `event` is now verified and fully typed
  switch (event.type) {
    case "payment.succeeded":
      await handlePaymentSucceeded(event.data);
      break;
    case "payment.failed":
      await handlePaymentFailed(event.data);
      break;
  }

  res.json({ received: true });
});

constructEvent does three things in one call: parse the raw body, verify the HMAC signature, and check the timestamp against a replay window (typically ±5 minutes). If any of those fail, it throws before returning a typed event object. The developer never touches raw JSON.

The anti-pattern is providing a verifySignature(payload, sig, secret) function that returns a boolean and expecting developers to remember to call it before parsing. They won't — not consistently, and not under deadline pressure.


Make Payload Types Load-Bearing

Typed event payloads are not a nice-to-have. They're what lets developers refactor webhook handlers confidently and catch breaking changes at compile time instead of in production.

The structure that works well is a discriminated union keyed on the event type field:

typescript
export type WebhookEvent =
  | PaymentSucceededEvent
  | PaymentFailedEvent
  | SubscriptionCreatedEvent
  | SubscriptionCancelledEvent
  | InvoicePaidEvent;

export interface PaymentSucceededEvent {
  type: "payment.succeeded";
  id: string;
  created: number;
  data: {
    payment_id: string;
    amount: number;
    currency: string;
    customer_id: string;
    metadata: Record<string, string>;
  };
}

When constructEvent returns a WebhookEvent, TypeScript narrows the type inside each case block automatically. A payment.succeeded handler gets full type safety on event.data without any additional casting.

The practical benefit: when you add a new field to the PaymentSucceededEvent type in a minor version bump, every consumer that accesses event.data benefits immediately. When you need to remove a field in a major version, the compiler tells them exactly which handlers to update.


Handle Idempotency Without Making It the Developer's Problem

At-least-once delivery means your consumers will receive duplicate events. Most developers know this in theory; most forget to handle it in practice. Your SDK can close most of this gap by making idempotency easy.

The simplest form: expose the event ID prominently and provide a convenience method to check for duplicates against a caller-supplied store.

typescript
// Minimal: the event ID is always at the top level, never buried in data.*
event.id        // "evt_01HW..."
event.idempotencyKey  // alias for event.id, explicitly named

// Developer wires in their own deduplication store
const client = new WebhookClient({
  secret: process.env.WEBHOOK_SECRET,
  deduplication: {
    async check(key: string): Promise<boolean> {
      return redis.exists(`wh:seen:${key}`);
    },
    async mark(key: string): Promise<void> {
      await redis.set(`wh:seen:${key}`, "1", "EX", 86400);
    },
  },
});

// Now constructEvent throws if the event was already processed
event = await client.constructEvent(req.body, req.headers["x-signature"]);

You're not opinionated about the storage layer — Redis, Postgres, DynamoDB, an in-memory map in tests — the developer supplies the adapter. You just wire it into the verification step so duplication is caught before any handler runs.

This pattern also makes testing idempotency straightforward: inject a map-backed implementation in your test suite and simulate a duplicate delivery.


Ship Framework Integrations, Not Just Core Utilities

Developers don't write webhook handlers in a vacuum. They write them in Express, Next.js API routes, Fastify, Go's net/http, Django, FastAPI. Your core SDK can be framework-agnostic, but you should ship first-party middleware for the top two or three frameworks your customers actually use.

The raw-body problem is a common integration failure point. Express parses req.body as JSON by default, but HMAC verification requires the raw bytes before parsing. The correct Express setup is:

typescript
// Wrong — body has already been parsed to a JS object, HMAC will fail
app.use(express.json());
app.post("/webhooks", handler);

// Correct — raw bytes preserved for the /webhooks route only
app.post("/webhooks", express.raw({ type: "application/json" }), handler);

Developers make this mistake constantly. A middleware helper that handles raw body buffering for them eliminates an entire class of "why is signature verification failing?" support tickets:

typescript
import { webhookMiddleware } from "@yoursaas/webhooks/express";

app.post(
  "/webhooks",
  webhookMiddleware({ secret: process.env.WEBHOOK_SECRET }),
  (req, res) => {
    // req.webhookEvent is verified and typed — raw body handled by middleware
    const event = req.webhookEvent;
    // ...
  }
);

The same pattern applies for Next.js App Router (which needs export const config = { api: { bodyParser: false } } and custom buffering), Fastify (which needs a content type parser), and Go's net/http (where you need to read and re-seal the body).


Testing Helpers Are Part of the SDK

A webhook SDK that doesn't ship testing utilities forces every developer to write their own test fixture factory. That's wasted time, and the fixtures are usually wrong (wrong timestamp, wrong HMAC algorithm, no replay protection).

Provide a test client that can construct signed payloads from typed objects:

typescript
import { WebhookTestClient } from "@yoursaas/webhooks/testing";

const testClient = new WebhookTestClient({ secret: "test_secret_whsec_..." });

it("handles payment.succeeded events", async () => {
  const payload = testClient.buildEvent({
    type: "payment.succeeded",
    data: {
      payment_id: "pay_test_123",
      amount: 4999,
      currency: "usd",
      customer_id: "cus_test_456",
      metadata: {},
    },
  });

  const response = await request(app)
    .post("/webhooks")
    .set("Content-Type", "application/json")
    .set("x-signature", payload.signature)
    .send(payload.body);

  expect(response.status).toBe(200);
  expect(mockHandlePaymentSucceeded).toHaveBeenCalledWith(
    expect.objectContaining({ payment_id: "pay_test_123" })
  );
});

buildEvent signs the payload with a valid HMAC and sets a current timestamp, so the test uses the real constructEvent path — not a mocked one. You're testing the actual verification code, not a stub. This catches the raw-body parsing mistake before it ships.

Also provide a buildExpiredEvent helper that sets a timestamp outside the replay window, so developers can test their rejection path too.


Version Your SDK Alongside Your API

SDK versionAPI compatibilityNotes
1.xAPI v1Initial release, basic event types
2.xAPI v1, v2Added v2 event types; v1 events still supported
3.xAPI v2 onlyDropped v1 event types after 18-month deprecation window

SDK major versions should map to API surface changes, not internal implementation changes. A developer pinned to 2.x should not get breaking type changes from a patch release.

The practical rule: once an event type ships in a released version, treat it as stable API. Adding fields is non-breaking (consumers ignore unknown fields). Renaming or removing fields is breaking (consumers that reference them by name will fail at compile time or runtime depending on their language).

This is where the type system pays dividends on your side too. If you have TypeScript definitions for all your event payloads, any breaking change is a compile error in your own test suite before it ships to customers.


What GetHook Provides Out of the Box

If you're using GetHook to deliver webhooks to your customers, you don't have to build the signing and verification layer yourself. GetHook signs every delivered event with an HMAC-SHA256 signature using a per-destination secret, and the signature format is compatible with standard webhook verification patterns. Your customers can verify GetHook-delivered events with any HMAC-SHA256 implementation — or with an SDK wrapper you build on top of GetHook's documented signature scheme.

For teams building the full SDK layer, GetHook's delivery attempt records give you the data to surface to your customers: which events were delivered, when, to which endpoints, and what the response was. That's the foundation for a good developer experience on the consumer side.


The SDK Is Part of Your Product

A webhook integration that requires developers to read five pages of documentation before they can verify their first event is a bad product, regardless of how reliable your delivery infrastructure is. The SDK is where your reliability guarantees meet your customers' code.

Build signature verification as the default path, not an optional add-on. Ship typed payloads that narrow correctly in switch statements. Handle the raw-body problem in your framework middleware so developers don't have to. Provide test helpers that exercise the real verification code path. Version the SDK alongside the API with a clear compatibility matrix.

These aren't polish features. They're what separates webhook integrations that work in production from ones that generate support tickets for the lifetime of the integration.

Start building with GetHook →

Stop losing webhook events.

GetHook gives you reliable delivery, automatic retry, and full observability — in minutes.