Verify webhook signatures

sevk signs every webhook delivery so your endpoint can prove a request came from sevk and was not modified on the way. The signing scheme is Svix-compatible, so you can verify with the bundled WebhookVerifier in each SDK or with the official svix library.

Why verify

Your webhook URL is public. Anyone who learns it can POST whatever they want at your handler. Signature verification keeps you safe in three places:

  • Authenticity. Only requests signed with your endpoint secret are accepted. Forged payloads from a third party are rejected before they touch your business logic.
  • Integrity. The signature covers the exact bytes of the request body. A proxy that rewrites JSON or strips a field invalidates the signature.
  • Replay protection. Each delivery carries a timestamp; verifiers reject anything outside a 5 minute tolerance window so a captured request cannot be replayed days later.

What sevk sends

Every signed delivery includes three headers in addition to the JSON body. A webhook created without a secret is unsigned: the `svix-signature` header is omitted and there is nothing to verify.

NameTypeDescription
svix-idstringUnique delivery id. Reused across retries, so it doubles as an idempotency key on your side.
svix-timestampunix secondsWhen sevk produced the delivery. Verifiers reject deliveries older than 5 minutes.
svix-signaturev1,<base64>Signature in v1 format. The Svix wire format allows multiple space-separated signatures, so verifiers split on spaces and accept the message if any candidate verifies; sevk sends exactly one signature per delivery.

Signature algorithm

sevk uses the Svix scheme: HMAC-SHA256 over `${svix-id}.${svix-timestamp}.${rawBody}`, keyed with your endpoint secret. The signature header value is v1,<base64>. The secret can be supplied two ways:

  • whsec_<base64>: Svix-style. Strip the whsec_ prefix and base64-decode the rest to get the HMAC key.
  • Raw UTF-8: anything that does not start with whsec_ is treated as raw bytes. Both forms verify the same wire format.

Important: the body must be the raw bytes you received over the wire, not a re-serialized JSON object. Frameworks that parse JSON before your handler runs will silently break verification.

Verifying with the SDK

Every sevk SDK exports a WebhookVerifier class. Construct it once with your endpoint secret, then call verify(body, headers) on each request. It throws a typed verification error on missing headers, expired timestamp, or signature mismatch.

Node / TypeScript

import { WebhookVerifier, WebhookVerificationError } from 'sevk'
const verifier = new WebhookVerifier(process.env.SEVK_WEBHOOK_SECRET!)
export async function POST(request: Request) {
const body = await request.text()
let event: { type: string, data: Record<string, unknown> }
try {
event = verifier.verify<typeof event>(body, {
'svix-id': request.headers.get('svix-id'),
'svix-timestamp': request.headers.get('svix-timestamp'),
'svix-signature': request.headers.get('svix-signature')
})
} catch (err) {
if (err instanceof WebhookVerificationError) {
return new Response('Invalid signature', { status: 401 })
}
throw err
}
// ... handle event
return new Response(null, { status: 200 })
}

Python

import os
from sevk import WebhookVerifier, WebhookVerificationError
verifier = WebhookVerifier(os.environ["SEVK_WEBHOOK_SECRET"])
@app.post("/webhooks/sevk")
def handle(request):
try:
event = verifier.verify(request.body, {
"svix-id": request.headers.get("svix-id"),
"svix-timestamp": request.headers.get("svix-timestamp"),
"svix-signature": request.headers.get("svix-signature"),
})
except WebhookVerificationError:
return Response(status=401)
# ... handle event
return Response(status=200)

PHP

<?php
use Sevk\WebhookVerifier;
use Sevk\WebhookVerificationException;
$verifier = new WebhookVerifier(getenv('SEVK_WEBHOOK_SECRET'));
try {
$event = $verifier->verify($body, [
'svix-id' => $_SERVER['HTTP_SVIX_ID'] ?? null,
'svix-timestamp' => $_SERVER['HTTP_SVIX_TIMESTAMP'] ?? null,
'svix-signature' => $_SERVER['HTTP_SVIX_SIGNATURE'] ?? null,
]);
} catch (WebhookVerificationException $e) {
http_response_code(401);
exit;
}

Ruby

require 'sevk'
verifier = Sevk::WebhookVerifier.new(ENV.fetch('SEVK_WEBHOOK_SECRET'))
post '/webhooks/sevk' do
body = request.body.read
begin
event = verifier.verify(body, {
'svix-id' => request.env['HTTP_SVIX_ID'],
'svix-timestamp' => request.env['HTTP_SVIX_TIMESTAMP'],
'svix-signature' => request.env['HTTP_SVIX_SIGNATURE']
})
rescue Sevk::WebhookVerificationError
halt 401
end
end

Go

package main
import (
"io"
"net/http"
"os"
sevk "github.com/sevk-io/sevk-go"
)
var verifier *sevk.WebhookVerifier
func init() {
v, err := sevk.NewWebhookVerifier(os.Getenv("SEVK_WEBHOOK_SECRET"))
if err != nil {
panic(err)
}
verifier = v
}
func handle(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
event, err := verifier.Verify(body, r.Header)
if err != nil {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
_ = event
w.WriteHeader(http.StatusOK)
}

Rust

use sevk::WebhookVerifier;
use std::collections::HashMap;
let verifier = WebhookVerifier::new(&std::env::var("SEVK_WEBHOOK_SECRET")?)?;
let headers: HashMap<String, String> = request_headers();
let body: String = read_body().await?;
match verifier.verify(&body, &headers) {
Ok(event) => {
// ... handle event
}
Err(_) => return Ok(HttpResponse::Unauthorized().finish()),
}

Java (Spring, Java 17+)

import io.sevk.WebhookVerifier;
import io.sevk.WebhookVerificationException;
import com.google.gson.JsonElement;
import java.util.Map;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class WebhookController {
private final WebhookVerifier verifier = new WebhookVerifier(System.getenv("SEVK_WEBHOOK_SECRET"));
@PostMapping("/webhooks/sevk")
public ResponseEntity<Void> handle(@RequestBody String body, @RequestHeader Map<String, String> headers) {
try {
JsonElement event = verifier.verify(body, headers);
// ... handle event
return ResponseEntity.ok().build();
} catch (WebhookVerificationException e) {
return ResponseEntity.status(401).build();
}
}
}

.NET

using Sevk;
var verifier = new WebhookVerifier(
Environment.GetEnvironmentVariable("SEVK_WEBHOOK_SECRET")!);
app.MapPost("/webhooks/sevk", async (HttpRequest request) =>
{
using var reader = new StreamReader(request.Body);
var body = await reader.ReadToEndAsync();
var headers = new Dictionary<string, string?>
{
["svix-id"] = request.Headers["svix-id"],
["svix-timestamp"] = request.Headers["svix-timestamp"],
["svix-signature"] = request.Headers["svix-signature"]
};
try
{
using var doc = verifier.Verify(body, headers);
return Results.Ok();
}
catch (WebhookVerificationException)
{
return Results.StatusCode(401);
}
});

Manual verification

You do not need an SDK to verify. Any HMAC-SHA256 implementation works as long as you cover the three steps below. The reference implementation in Node uses only node:crypto:

import { createHmac, timingSafeEqual } from 'node:crypto'
const SECRET = process.env.SEVK_WEBHOOK_SECRET! // "whsec_..." or raw
const key = SECRET.startsWith('whsec_')
? Buffer.from(SECRET.slice(6), 'base64')
: Buffer.from(SECRET, 'utf-8')
export async function POST(request: Request) {
const id = request.headers.get('svix-id')!
const timestamp = request.headers.get('svix-timestamp')!
const signatureHeader = request.headers.get('svix-signature')!
const body = await request.text()
// 1. Reject replays older than 5 minutes
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return new Response('Stale', { status: 401 })
}
// 2. Recompute the signature
const expected = 'v1,' + createHmac('sha256', key)
.update(`${id}.${timestamp}.${body}`)
.digest('base64')
// 3. Constant-time compare against each candidate
const match = signatureHeader.split(' ').some(candidate =>
candidate.length === expected.length
&& timingSafeEqual(Buffer.from(candidate), Buffer.from(expected))
)
if (!match) return new Response('Invalid signature', { status: 401 })
const event = JSON.parse(body)
// ... handle event
return new Response(null, { status: 200 })
}

The same three steps apply in any language: reject stale timestamps first, recompute the HMAC over id.timestamp.body, and compare using a constant-time function. String === String is not safe because comparison time depends on how far in the string the mismatch happens.

Errors and replay protection

NameTypeDescription
Missing required svix headersverification errorOne of svix-id, svix-timestamp, or svix-signature is absent. Common cause: a proxy or framework dropped headers. Make sure your handler reads them from the raw incoming request.
Invalid timestamp headerverification errorsvix-timestamp is not a base-10 integer. sevk always sends Unix seconds; if you see this in practice, something between sevk and your handler is rewriting headers.
Timestamp outside tolerance windowverification errorThe delivery is more than 5 minutes old or more than 5 minutes in the future. Either your server clock is skewed or this is a replay.
Signature mismatchverification errorNone of the signatures in svix-signature match the body under your secret. Usually a wrong secret, a body that was re-serialized before verification, or a forged request.

Always return a 4xx response on verification failure. sevk treats 2xx as a successful delivery and stops retrying; if you 200 a forged request, you lose the chance to investigate from delivery logs.