Signing & verification

Updated May 02, 2026

Why signing matters

Webhook providers sign every request with a secret shared between them and your server. Your handler verifies the signature to confirm the request genuinely came from the provider — not a replay attack or a malicious third party.

The problem: signatures include a timestamp. If you capture a webhook at 10:00 and replay it at 10:06, Stripe rejects it:

Webhook timestamp outside tolerance (5-minute window exceeded)

Splithook solves this by re-signing the payload using your destination's signing secret and a fresh now() timestamp before delivery. Your handler sees a valid, verifiable signature regardless of when the original event was captured.

Signing secrets

Signing secrets are stored encrypted in the database (AES-256-GCM with a key derived from APP_SECRET). The plaintext is only ever in memory during request processing — it is never logged and never exposed via the API.

Manage secrets at Settings → Signing secrets:

A secret can be shared across multiple destinations.

Supported providers

Stripe

Stripe signs with HMAC-SHA256. The signature header has two components:

stripe-signature: t=1714900000,v1=abc123...

On replay: Splithook replaces t with now() and recomputes v1. Your handler's 5-minute tolerance window resets.

Verification in your handler:

import Stripe from 'stripe';
const event = stripe.webhooks.constructEvent(rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET);

See Stripe provider guide for setup details.

GitHub

GitHub signs with HMAC-SHA256:

x-hub-signature-256: sha256=abc123...

The signed string is the raw request body (no timestamp).

On replay: Splithook recomputes HMAC-SHA256(secret, body). Because there is no timestamp in the GitHub scheme, replays work without any timestamp manipulation.

See GitHub provider guide.

Shopify

Shopify signs with HMAC-SHA256 and base64-encodes the result:

x-shopify-hmac-sha256: abcABC123==

On replay: Splithook recomputes and re-encodes. No timestamp involved.

See Shopify provider guide.

Twilio

Twilio signs differently: the signed string is the full request URL concatenated with all POST parameters sorted by key and concatenated as key + value.

x-twilio-signature: abc123=

On replay: Splithook reconstructs the signature from the stored URL and parameters. The URL must match exactly — configure the destination URL identically to the one Twilio configured.

See Twilio provider guide.

Svix

Svix uses three headers:

svix-id: msg_abc123
svix-timestamp: 1714900000
svix-signature: v1,base64encoded...

The signed string is {svix-id}.{svix-timestamp}.{body}.

On replay: Splithook replaces svix-timestamp with now(), keeps svix-id, recomputes the signature.

See Svix provider guide.

How auto-detection works

When a webhook arrives, Splithook inspects the headers to identify the provider:

Header present Detected provider
stripe-signature Stripe
x-hub-signature-256 GitHub
x-shopify-hmac-sha256 Shopify
x-twilio-signature Twilio
svix-signature Svix

If a signing secret for the detected provider exists in the workspace, the dashboard suggests attaching it to the destination: "Detected: Stripe — configure a signing secret for this destination?"

Signing modes in detail

passthrough

Splithook forwards the original stripe-signature (or equivalent) header exactly as received. Use this when your staging/production handler uses the same secret as the provider configured — live traffic from the provider to a forwarded destination.

strip

Splithook drops the signature header entirely. Use for internal consumers that don't verify origin auth — analytics workers, internal queues, logging services.

re_sign

Splithook recomputes the signature using your destination's attached signing secret and the provider's algorithm. Fresh timestamp. Use for:

The re-signing flow

1. ForwardWebhookHandler receives job
2. Fetches raw body from Redis
3. Evaluates destination filter → match
4. Signing mode == re_sign → looks up signing secret
5. Detects provider from original headers
6. Calls WebhookSignerInterface::sign(body, secret, headers)
7. Gets back SignedPayload{headers: {...}, body: ...}
8. POSTs to destination with merged headers
9. Logs result in ReplayLog

The body is never modified — only the signature-related headers change.