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.
| Name | Type | Description |
|---|---|---|
svix-id | string | Unique delivery id. Reused across retries, so it doubles as an idempotency key on your side. |
svix-timestamp | unix seconds | When sevk produced the delivery. Verifiers reject deliveries older than 5 minutes. |
svix-signature | v1,<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 thewhsec_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 eventreturn new Response(null, { status: 200 })}
Python
import osfrom sevk import WebhookVerifier, WebhookVerificationErrorverifier = 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 eventreturn Response(status=200)
PHP
<?phpuse 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' dobody = request.body.readbeginevent = 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::WebhookVerificationErrorhalt 401endend
Go
package mainimport ("io""net/http""os"sevk "github.com/sevk-io/sevk-go")var verifier *sevk.WebhookVerifierfunc 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}_ = eventw.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;@RestControllerpublic 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 eventreturn 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 rawconst 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 minutesif (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {return new Response('Stale', { status: 401 })}// 2. Recompute the signatureconst expected = 'v1,' + createHmac('sha256', key).update(`${id}.${timestamp}.${body}`).digest('base64')// 3. Constant-time compare against each candidateconst 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 eventreturn 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
| Name | Type | Description |
|---|---|---|
Missing required svix headers | verification error | One 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 header | verification error | svix-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 window | verification error | The 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 mismatch | verification error | None 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.