Back to Blog
Security

Your SDK Now Ships the Webhook Verifier. Read It Before You Trust It.

On September 24, 2026, Fern's C# SDK generator began emitting a shared WebhooksHelper from one API-wide signature setting. Here is what a generated webhook verifier cannot know about your receiver, and what to test before you delete your own code.

WebhookVault Team · Webhook Infrastructure Experts10 min read
A laptop on a white desk showing a dark code editor with a file tree and stylesheet code, with a second open laptop and a hand resting on a mouse blurred in the background

The verifier you used to write is now generated

You bump the provider's SDK, and a new class shows up in the diff: a webhook helper. Your hand-written HMAC check suddenly looks like debt. Delete forty lines, call one method, ship it.

Plenty of codebases will hit that moment soon. On September 24, 2026, Fern's C# SDK generator released version 2.85.0 with a one-line changelog entry: it now generates a shared WebhooksHelper from the API-wide api.settings.webhook-signature setting in generators.yml. The helper is produced even when the API definition models no webhooks at all. One setting, and every SDK built from that definition carries a signature verifier.

Fern isn't the first. Stainless-generated SDKs already do this: the OpenAI Node SDK has client.webhooks.unwrap(), which verifies the Standard Webhooks headers and parses the event in one call. Stripe has had constructEvent for years. The pattern is settled. Providers would rather you call their code than reimplement their signature scheme, and they are right about that.

The helper is fine. The risky part is assuming that code generated from a provider-side config can see your side of the request. It can't. It sees the bytes and headers you hand it, and whatever defaults the generator chose. Everything else is still yours.

What a config flag cannot know about your receiver

A setting like webhook-signature describes the sender's scheme: which algorithm, which header, how the signed string is built, maybe the encoding of the secret. That is the part providers get right, because they wrote the sender.

What the setting does not describe is your stack. It does not know your framework parsed the JSON before your route ran. It does not know you sit behind a proxy that rewrites line endings, or that your clock drifts. It does not know whether you rotate secrets, or whether your deployment injects the secret from an environment variable that is sometimes empty.

Fern's changelog does not say what the generated helper checks beyond the signature itself, and you should not guess. Open the generated file. It's plain code in your repo. Ten minutes. You are looking for four things: what input type it accepts for the body, whether it checks a timestamp and with what tolerance, how it handles several signatures in one header, and what it does when the secret is missing.

The raw body is still your problem

Every HMAC-based scheme signs bytes, exactly as they left the sender. A generated helper cannot fix a body your framework already decoded and re-encoded.

This is how most correct verifiers fail. In Express, a global express.json() registered before your webhook route reads the stream first. Your route-level express.raw() then sees a consumed stream and quietly skips, and req.body is a parsed object. Pass that to the helper and verification fails on every request. Worse, someone "fixes" it by calling JSON.stringify(req.body), which works in testing and fails the first time a payload contains a Unicode escape or a key order the serializer changes.

import express from "express";
import Stripe from "stripe";

const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

// Register the webhook route BEFORE any global JSON parser.
app.post(
  "/webhooks/stripe",
  express.raw({ type: "application/json" }),
  (req, res) => {
    let event: Stripe.Event;
    try {
      event = stripe.webhooks.constructEvent(
        req.body, // a Buffer, untouched
        req.headers["stripe-signature"] as string,
        process.env.STRIPE_WEBHOOK_SECRET!
      );
    } catch {
      return res.status(400).send("invalid signature");
    }
    // enqueue event.id and return quickly
    res.sendStatus(200);
  }
);

app.use(express.json()); // everything else

In a Next.js route handler the equivalent is reading await request.text() once and passing that string to the helper, never request.json(). Check the helper's signature too. If it accepts a parsed object, that is a design smell: it must be serializing internally, and that can only match the sender's bytes by luck.

Read the tolerance before you trust the timestamp check

Signatures stop forgery. They do nothing against an attacker who captured a valid request and sends it again. That is what timestamps are for, and it is where generated helpers differ the most.

Stripe's constructEvent takes an optional tolerance argument that defaults to 300 seconds. The Standard Webhooks reference libraries use the same five-minute window, checked in both directions so a timestamp from the future fails too. GitHub's X-Hub-Signature-256 has no timestamp at all, so any GitHub verifier, generated or not, gives you zero replay protection and you need delivery-ID tracking instead.

So find the tolerance in the generated code and write it down. Then ask two questions. Is it checked at all, or only parsed? And can you change it? A five-minute window is a sensible default, but it is a decision. If your workers verify after pulling from a queue rather than at the edge, a backlog of six minutes turns every valid delivery into a failure. Verify at ingress, before the queue, and the window means what it says. The replay side of this is covered in more depth in /blog/webhook-security-verification-replay-attacks.

Several signatures in one header

Rotation is where hand-written verifiers usually break, and generated ones are not immune.

During a secret rotation, a sender that follows Standard Webhooks puts more than one signature in the webhook-signature header, space-separated, each prefixed with a version like v1,. Stripe does the same thing with multiple v1= entries in Stripe-Signature. A correct verifier passes if any one of them matches. A naive one splits on the first separator, compares one value, and starts rejecting perfectly valid traffic the minute the provider begins a rotation you did not schedule.

The opposite case matters too. If you hold two secrets on your side during a rotation, the helper probably takes one. Then you need to call it twice, or wrap it, and the wrapper is code you own again. Polar's split between legacy and Standard Webhooks signing after a secret regeneration showed how easily "same header, same prefix" hides a different key derivation; see /blog/webhook-secret-regeneration-signing-scheme.

Where the secret comes from

A generated helper usually takes the secret as a constructor argument, and some fall back to an environment variable when you pass nothing. Convenient. Also where the quietest failure in this area lives.

Look for what happens with an empty string. A verifier that computes an HMAC with an empty key will happily produce a value, and if any path accepts a missing or empty secret as "verification disabled", a misconfigured deploy turns your endpoint into one that accepts anything. Make the startup fail instead. Read the secret once at boot, refuse to start if it is empty, and pass it explicitly.

Encoding is the other trap. Standard Webhooks secrets look like whsec_ followed by base64, and the key is the decoded bytes, not the string. Some providers use the whsec_ prefix on secrets they treat as raw strings. A helper generated for one provider will decode correctly for that provider. Copy it to another integration because "it is the same format", and you are back to guessing.

Your status code is part of the contract

Most helpers throw on failure. What you return afterwards is up to you, and the sender reads it as an instruction.

Return 400 or 401 for a bad signature, and many senders stop retrying that delivery. That is correct for forged traffic. It is a disaster when the cause was your parser or a secret you deployed wrong, because now the real event is gone. Knock's October rewrite made exactly those codes non-retryable, which we went through in /blog/knock-webhook-retries-non-retryable-status-codes.

So separate the cases. Signature mismatch with a valid-looking header: log the delivery ID and return 4xx. Secret missing, body type wrong, helper threw something unexpected: that is your bug, so return 5xx and let the sender retry once you have fixed it. Do not let a generic try/catch flatten both into the same response.

The retry change three entries down

The same Fern changelog has a second entry worth reading if you call the provider's API from inside a webhook handler. Version 2.84.3 changed the C# RawClient so the configured Timeout applies to each retry attempt individually, instead of one budget shared across all attempts and backoff delays. The changelog spells out the worst case: roughly Timeout * (MaxRetries + 1) plus backoff.

That matters because senders give you very little time. GitHub expects a response within 10 seconds. Shopify gives you five. If your handler does fetch-on-notify inline, retrieving the full object before acknowledging, an SDK upgrade can stretch that call from a bounded few seconds to well past the sender's deadline. The sender times out, records a failure, and retries a delivery you are still processing. Now you have a duplicate and a failure count creeping toward endpoint disablement.

// Webhook endpoint: verify, enqueue, acknowledge. No outbound API calls here.
app.MapPost("/webhooks/provider", async (HttpRequest request, IEventQueue queue) =>
{
    using var reader = new StreamReader(request.Body);
    var rawBody = await reader.ReadToEndAsync();

    if (!verifier.IsValid(rawBody, request.Headers))   // your wrapper around the generated helper
        return Results.StatusCode(400);

    await queue.EnqueueAsync(rawBody);                  // the worker fetches current state later
    return Results.Accepted();
});

The fix is old advice that a changelog just made urgent: acknowledge first, fetch later. Move the API call to the worker. A long timeout there costs you some latency, and nobody drops a delivery over it.

Test the helper with vectors you control

Don't trust a verifier you've only seen pass. Build a small test that signs a payload yourself, then confirms the helper rejects what it should. For a Standard Webhooks sender this takes a few lines.

import { createHmac } from "node:crypto";
import { test } from "node:test";
import assert from "node:assert/strict";
import { Webhook } from "standardwebhooks";

const rawKey = Buffer.from("test-key-for-signature-vectors!!");
const secret = "whsec_" + rawKey.toString("base64");
const body = JSON.stringify({ type: "invoice.paid", data: { id: "in_1" } });

function headers(ts: number, payload: string, id = "msg_1") {
  const mac = createHmac("sha256", rawKey)
    .update(`${id}.${ts}.${payload}`)
    .digest("base64");
  return {
    "webhook-id": id,
    "webhook-timestamp": String(ts),
    "webhook-signature": `v1,wrongsig v1,${mac}`, // second signature is the valid one
  };
}

test("generated verifier behaves", () => {
  const wh = new Webhook(secret);
  const now = Math.floor(Date.now() / 1000);

  assert.doesNotThrow(() => wh.verify(body, headers(now, body)));
  assert.throws(() => wh.verify(body, headers(now - 600, body)));      // stale
  assert.throws(() => wh.verify(body, headers(now + 600, body)));      // future
  const pretty = JSON.stringify(JSON.parse(body), null, 2);
  assert.throws(() => wh.verify(pretty, headers(now, body)));          // re-serialized body
});

Swap the reference library for the generated helper and run the same assertions. Add one case your stack can produce: the body exactly as your framework hands it to the route. If that one fails, look at your framework. The helper was fine all along.

When to keep your own verifier

Use the generated helper by default. It follows the provider's scheme and gets updated when they change it. That alone kills the bugs where you misread their docs.

Keep a thin wrapper of your own around it, though. The wrapper reads the secret at boot and fails loud. It holds both secrets during your rotation. It turns exceptions into the right status code. It records which signature version matched, so you know when the old one stops appearing. None of that belongs in a generated file you will overwrite on the next SDK bump.

And keep your own verifier outright in one situation: when you receive from many providers through one endpoint, or through a gateway that rewrites payloads. There the generated helpers multiply, each with its own defaults, and the place to enforce one policy is code you control. The gateway case has its own trap, covered in /blog/webhook-payload-transformation-gateway.

Frequently asked questions

Does a generated webhook helper replace signature verification tests? No. It replaces the HMAC code, not the tests. Your tests should cover the body your framework actually delivers, stale and future timestamps, multiple signatures in one header and an empty secret, because those depend on your stack and not on the generator.

Should verification happen before or after the request is queued? Before. Verify at the edge with the raw body, then enqueue. Verifying in a worker means queue lag eats into the timestamp tolerance, and a backlog longer than five minutes would make every valid delivery look like a replay.

What if the generated helper only accepts a parsed object instead of raw bytes? Treat it as a bug to report and wrap around it. A helper that serializes internally can only match the sender's signature when its serializer happens to produce identical bytes, which breaks on key order, whitespace or Unicode escaping.

Is it safe to share one generated helper across several providers that all use whsec_ secrets? Only if each provider documents the same scheme, usually Standard Webhooks. The prefix alone proves nothing: some providers decode the base64 part into key bytes, others use the whole string as the key, and a shared helper will silently reject one of them.

Do I need to change anything after upgrading to Fern's C# generator 2.84.3 or later? Only if you make SDK calls inside a webhook handler or under a strict deadline. The timeout now applies per attempt, so the worst case grows to roughly the timeout times the number of attempts plus backoff. Cap retries for that call or move it to a background worker.

Related posts