Signing & verification
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:
- New — enter the secret value and pick a provider algorithm.
- Rotate — generate a new secret; the old one is invalidated immediately.
- Delete — removes the secret. Destinations using it fall back to
passthrough.
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...
tis the Unix timestamp (seconds) at signing time.v1isHMAC-SHA256(secret, "{t}.{body}").
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.
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.
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.
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:
- Replays (always needed for timestamp-based schemes)
- Forwarding from production to staging (different secrets)
- Local development via tunnel
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.