Standard Webhooks (Svix)
Standard Webhooks is a published specification for signing webhooks, and Svix's delivery service implements it. Any sender that follows the spec verifies under a single Hookbase provider, so there is nothing per-sender to configure beyond the endpoint's own secret.
Hookbase registers this scheme as standard-webhooks and accepts svix as an alias for it. Both
values select exactly the same verification, so a source created as either one behaves identically —
the Clerk and Resend pages
create their sources with "provider": "svix" for that reason. This page is the reference for the
scheme itself.
Setup
1. Create a Source in Hookbase
curl -X POST https://api.hookbase.app/api/sources \
-H "Authorization: Bearer whr_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Standard Webhooks Production",
"slug": "standard-webhooks",
"provider": "standard-webhooks",
"signingSecret": "whsec_..."
}'Save your webhook URL:
https://api.hookbase.app/ingest/{orgSlug}/standard-webhooksWarning
Store the whsec_ secret exactly as the sender gives it to you. The spec states the signing
secret is base64 encoded and prefixed with whsec_, and the HMAC key is the decoded bytes — not
the characters of the string. Hookbase does both steps for you: it strips whsec_ and base64-decodes
the rest before hashing.
Decoding the secret yourself before storing it, or re-encoding it, is the single most common way an
implementation of this spec goes wrong. It produces a perfectly well-formed digest that never matches
anything, and every event arrives with signature_valid: false. If what is left after whsec_ is not
valid base64 at all, no candidate can be compared and nothing verifies.
2. Create the Webhook at the Sender
The steps differ per sender, but the shape does not:
- Add an endpoint and set its URL to your Hookbase ingest URL
- Select the events you want delivered
- Copy the endpoint's signing secret — it starts with
whsec_— and store it on your Hookbase source assigningSecret - If the sender offers several endpoints, note that each one has its own secret. They are not interchangeable, and pointing two endpoints at one source means only one of them will verify
3. Create Destinations and Routes
# Create a destination
curl -X POST https://api.hookbase.app/api/destinations \
-H "Authorization: Bearer whr_your_api_key" \
-H "Content-Type: application/json" \
-d '{"name": "Webhook Handler", "slug": "webhook-handler", "url": "https://api.myapp.com/webhooks/standard"}'
# Create a route
curl -X POST https://api.hookbase.app/api/routes \
-H "Authorization: Bearer whr_your_api_key" \
-H "Content-Type: application/json" \
-d '{"name": "Standard Webhooks to Handler", "sourceId": "src_...", "destinationId": "dst_..."}'A route carries one destination. To fan the same source out to several endpoints, create one route per destination.
Signature Verification
The scheme spreads three values across three headers, and all three are part of the signature:
| Header | Spec spelling | Svix spelling |
|---|---|---|
| Message id | webhook-id | svix-id |
| Timestamp | webhook-timestamp | svix-timestamp |
| Signature | webhook-signature | svix-signature |
Hookbase reads both spellings. The names are tried in order — the spec spelling first, then the Svix spelling — and the first name that is present decides, rather than the first one that happens to verify. That distinction matters: accepting whichever name verifies would let a sender attach a bogus value under one spelling and a valid one under the other and still pass.
The signed string is {id}.{timestamp}.{body} — the message id, a literal dot, the timestamp,
another dot, then the raw request body. The digest is base64 HMAC-SHA256, and the signature
header is a space-delimited list of v1,<signature> entries:
webhook-signature: v1,dtr0m8n5QjhBQCPcyK4WBQrMAj+jbt/ezl7FZzp4qgI= v1,m2jbYVYacrQRtvJUThM22koUByQzjKxbry4kk5dimps=Senders include more than one entry while a secret is being rotated, so every v1 entry is treated
as a candidate and any match is a pass. Entries whose version is not v1 are ignored rather than
compared: an unknown version is a scheme Hookbase has not implemented, and comparing a value produced
by different rules would be meaningless.
Hookbase verifies this automatically once the source has both fields set:
{
"provider": "standard-webhooks",
"signingSecret": "whsec_..."
}Info
The timestamp is checked, not just signed. Hookbase rejects a signature whose timestamp is more than 300 seconds away from the receiving clock, and a non-numeric timestamp is a failure rather than a skipped check. A correctly signed request that arrives outside that window, or is replayed later from a capture, does not verify — which is the whole reason the sender puts a timestamp in the signing string.
Once events are arriving with signature_valid: true, set rejectInvalidSignatures: true on the
source to have unverified requests refused with 401 instead of stored. It defaults to false so
that a misconfigured secret costs you a badge rather than your traffic.
Common Events
The specification covers transport and signing, not payload shape, so there is no canonical event
list — the events are whatever the sender defines. Many senders use a type plus data envelope:
{
"type": "user.created",
"data": {
"id": "user_2abc123def456",
"email": "jane@example.com"
}
}That envelope is a convention of individual senders rather than part of the spec. Check the sender's own event catalog before writing filters against it, and see the Clerk and Resend pages for two concrete examples.
GitLab is worth calling out here: its newer signing token mode is this scheme verbatim, so a GitLab webhook configured that way should use this provider rather than gitlab.
Transform Examples
A javascript transform receives the parsed payload and returns the body Hookbase delivers.
Normalize the Envelope
Senders disagree about what the event name field is called. Reading the three common spellings keeps one handler working across all of them.
function transform(payload) {
const name = payload.type || payload.event || payload.event_type || null;
return {
event: name,
received_at: new Date().toISOString(),
data: payload.data || payload
};
}Slack Notification
function transform(payload) {
const name = payload.type || payload.event || payload.event_type || "unknown";
const data = payload.data || {};
return {
text: `Webhook received: ${name}`,
blocks: [
{
type: "section",
fields: [
{ type: "mrkdwn", text: `*Event:*\n${name}` },
{ type: "mrkdwn", text: `*Object:*\n${data.id || "unknown"}` }
]
}
]
};
}Filter Examples
Filter conditions read dotted paths out of the payload. logic must be AND or OR.
One Event Family
{
"name": "User Events",
"logic": "AND",
"conditions": [
{
"field": "type",
"operator": "starts_with",
"value": "user."
}
]
}Either Envelope Spelling
{
"name": "Named Events Only",
"logic": "OR",
"conditions": [
{
"field": "type",
"operator": "exists",
"value": ""
},
{
"field": "event_type",
"operator": "exists",
"value": ""
}
]
}Headers
| Header | Description |
|---|---|
webhook-id | Unique message id; part of the signed string |
webhook-timestamp | Unix seconds; part of the signed string and checked against a 300s tolerance |
webhook-signature | Space-delimited list of v1,<base64 HMAC-SHA256> |
svix-id | Svix spelling of webhook-id |
svix-timestamp | Svix spelling of webhook-timestamp |
svix-signature | Svix spelling of webhook-signature |
All three values are verified at ingest. They are not replayed to your destination: a delivery to an
HTTP destination carries Hookbase's own X-Event-ID and X-Delivery-ID headers plus whatever the
destination is configured to add, so your handler should read what it needs out of the payload rather
than trying to verify the signature a second time.
Troubleshooting
Signature Verification Failed
- Confirm the source's
providerisstandard-webhooksorsvix— a source left oncustomreadsX-Signature,X-Webhook-SignatureandX-Hub-Signature-256, none of which this scheme sends, and a source with a secret but no provider verifies as if it werecustom - Confirm
signingSecretis stored verbatim,whsec_prefix included. Hookbase strips the prefix and base64-decodes the remainder itself; doing either step yourself breaks the key - Confirm the secret belongs to the endpoint that is actually delivering
- Check the clock. The signed timestamp must be within 300 seconds, so a delivery replayed from a saved request will not verify even though its signature is genuine
- Confirm all three headers arrive. The id and the timestamp are inputs to the signature, so a proxy that strips either one leaves nothing to verify against
No Events Arriving
- Check the endpoint is enabled at the sender and pointed at the ingest URL, org slug and source slug included
- Check the endpoint's event subscriptions — an endpoint with no events selected sends nothing
- If the source has
rejectInvalidSignatures: true, a wrong secret shows up as401responses at the sender rather than as stored events. Turn it off, confirmsignature_valid: true, turn it back on
Duplicate Events
The spec defines webhook-id as the message's unique identifier and signs it, so a replayer cannot
vary it without breaking the signature. That makes it the strongest dedup key available in any of these
schemes, and it is what Hookbase uses for this provider when the source has dedupEnabled: true and
the default auto strategy: it reads webhook-id, falling back to svix-id, and falls back to hashing
the payload only if neither is present. Set dedupStrategy to provider_id if you would rather skip
deduplication entirely than key on the payload hash.
Separately from that, a source with a signing secret gets a 24-hour payload-hash replay check on every
request whose signature verified, whether or not dedupEnabled is on.