Inbound Webhooks

Verify the sender — HMAC-signed webhook delivery

Anyone on the internet can send a POST to your endpoint. Signing proves the request came from the provider you trust. Learn how to implement verification without getting the details wrong.

Why webhook signatures matter

A webhook endpoint is a public HTTP path you expose. Any attacker who knows the URL can POST whatever they want to it. If your webhook handler creates orders, charges credit cards, or updates user data without verification, you've handed them the keys.

Spoofed requests

Without signature verification, an attacker sends a fake webhook that your system treats as real. The signature proves it came from the provider you trust.

Replay attacks

An attacker captures an old signed request and sends it again. Signature schemes include a timestamp to reject replays older than your tolerance window.

Man-in-the-middle tampering

HMAC-SHA256 over the raw request body prevents tampering in transit. If a single byte changes, the signature no longer matches.

How webhook signing works

Every provider uses HMAC-SHA256, but wire formats differ. The key details: the body is hashed before parsing, the secret must be stripped and decoded before use, and comparison must be timing-safe.

The three-step verification

1

Extract signature from header

The provider sends the signature in a header — Stripe uses X-Stripe-Signature, GitHub uses X-Hub-Signature-256, Shopify uses X-Shopify-Hmac-SHA256. The header name and format are provider-specific.

2

Hash the raw request body

Compute HMAC-SHA256 over the *raw bytes* received from the network, before JSON parsing. Parse-then-hash is the most common mistake and will never match.

3

Timing-safe compare

Compare the computed hash to the signature from the header using a constant-time comparison. A naive string comparison leaks timing information attackers can exploit.

Four mistakes that break verification

Hashing the parsed JSON

JSON parsers normalize whitespace and key order. The signature was computed over the exact bytes received, not the normalized structure. Use req.rawBody, not JSON.stringify(body).

Forgetting to decode the secret

Secrets arrive base64-encoded (Stripe prefix: whsec_, GitHub prefix: sha256=). Decode first. Using the encoded string as the HMAC key produces a signature that never matches.

Plain string comparison

Comparing with == or string equality leaks timing. A 32-character signature has 256 possible first characters; an attacker brute-forcing can measure how long your comparison takes on each guess.

Rejecting old signatures after rotation

Some providers keep the previous secret valid for a grace window after a rotation — Stripe, for example, lets you delay the old secret's expiry by up to 24 hours, and signs with every active secret during that time. Others expire the old secret the moment you replace it. Check your provider before assuming either, and make sure your verification code tries the previous secret rather than rejecting outright.

Secret rotation without downtime

When you suspect a secret was compromised or want to rotate for hygiene, you need the old secret to stay valid briefly so in-flight requests don't fail.

The safe rotation flow

The safe pattern is a dual-validation window: the new secret becomes active immediately, the old one stays valid for a defined period, and requests signed with either key are accepted so no legitimate webhook fails mid-rotation. On Centrali's outbound deliveries that window is 24 hours, and both keys sign during it. Inbound, the window is whatever your sender implements — Stripe lets you choose up to 24 hours; other providers cut over immediately.

1. Generate a new secret (provider dashboard or API call)

2. The sender starts signing with the new key immediately

3. During the grace window, both old and new keys verify

4. When the window closes, the old secret stops working

5. Legitimate requests signed during the rotation window still pass verification

In your verification code: Store both the current secret and the previous secret (if one exists). Try verification with the current secret first, then the previous one if the first fails. Keep the previous secret for as long as your sender's grace window lasts, then drop it.

Provider presets handle the differences

Every provider differs in header names, encoding, and signature format. Rather than hardcoding these details, Centrali includes presets for the most common senders.

ProviderHeaderFormat
StripeX-Stripe-Signaturet=<timestamp>,v1=<base64-signature>
GitHubX-Hub-Signature-256sha256=<hex-signature>
ShopifyX-Shopify-Hmac-SHA256<base64-signature>
SlackX-Slack-Request-Timestamp + X-Slack-Request-Signaturev0=<hex-signature>
Svix-backed senders (Clerk, Resend, Loops, OpenAI, Brex)svix-id, svix-timestamp, svix-signaturev1,<base64-signature>

Each preset supplies the header name, extraction pattern, algorithm, encoding, and optional timestamp tolerance. Set the provider and signing secret, and the verification happens automatically.

Your function receives verified requests only

Signature verification happens before your function runs. The request body in executionParams.payload has already been verified by Centrali.

async function run() {
const event = executionParams.payload;
// By this line, Centrali has already verified the signature.
// You don't need to verify it again — the request is guaranteed authentic.
const record = await api.createRecord('inbound-events', {
provider: event.provider,
eventType: event.type,
payload: event,
receivedAt: new Date(),
});
return { success: true, recordId: record.id };
}

Centrali verifies the signature at the HTTP layer — before JSON parsing, before your function runs. You focus on the business logic; we handle the cryptography.

Step-by-step setup for GitHub webhooks

Centrali vs. rolling your own

Signature verification is straightforward in concept but error-prone in practice. The four most common mistakes lead to security gaps or integration failures.

Rolling your own

  • Remember to hash the *raw* request body, not parsed JSON
  • Decode the secret from the correct encoding (base64? base64url? plain?)
  • Use crypto.timingSafeEqual, not == or string equality
  • Handle secret rotation gracefully with a dual-validation window
  • Support multiple providers — each with different header names and formats
  • Test timing-safe comparison against timing attacks

Centrali

  • Set provider and secret once
  • Verification happens at the HTTP layer
  • Invalid signatures are rejected before your function runs
  • Secret rotation handled automatically
  • Built-in presets for Stripe, GitHub, Shopify, Slack, and Svix-backed senders
  • Raw path available for custom schemes

When you might need a dedicated webhook service

Centrali handles inbound webhook signature verification, storage, and triggering as part of your backend platform. If webhooks ARE your product — you sell webhooks-as-a-service, offer white-label customer portals, or need per-tenant embedded Event Logs — dedicated services like Svix and Hookdeck are built for that scale. Centrali is for teams where webhooks are one piece of a larger backend.

Verify your first webhook today

Sign up, create a workspace, and set up signature verification in under 5 minutes. No credit card required.

5-minute setup. No credit card.

Read the verification guide