Verifying Webhook Signatures
Every signed delivery lets the receiver prove it came from Sparrow and wasn’t replayed or modified. This page shows how to verify it in Go, plus a sample helper for other common languages: one file you copy into your project, with no package to install.
What gets signed
Section titled “What gets signed”Sparrow signs in the Standard Webhooks format. A signed delivery carries three headers:
| Header | Value |
|---|---|
webhook-id | msg_<delivery-id> |
webhook-timestamp | Unix seconds when the delivery was sent |
webhook-signature | Space-separated signatures, e.g. v1,<base64> v1a,<base64> |
The signed message is {webhook-id}.{webhook-timestamp}.{raw body}:
v1,is HMAC-SHA256 keyed with the webhook’swebhook_secret. Awhsec_…secret (the default) is base64-decoded first; any other secret is used as raw bytes.v1a,is Ed25519, verified with the webhook’s hexsigning_public_key, which is present when the webhook usessignature_type: "ed25519". There’s no shared secret, so receivers only ever hold a public key.
Every helper rejects a delivery when:
- a header is missing;
- the timestamp is more than 5 minutes off in either direction (replay protection);
- no signature of the requested scheme matches.
Several signatures may appear during secret rotation, and matching any one of them is enough.
Get the helper and verify
Section titled “Get the helper and verify”Pick your language: the choice applies to every example on this page.
go get github.com/sarathsp06/sparrow/pkg/signatureimport ( "io" "net/http"
"github.com/sarathsp06/sparrow/pkg/signature")
func webhook(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) // raw bytes if err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } if err := signature.VerifyHMAC(body, r.Header, webhookSecret); err != nil { http.Error(w, "invalid signature", http.StatusUnauthorized) return } // Ed25519 instead: signature.VerifyEd25519(body, r.Header, signingPublicKeyHex) w.WriteHeader(http.StatusOK)}Custom clock or tolerance: signature.Verifier{Tolerance: 10 * time.Minute}.VerifyHMAC(...).
curl -O https://raw.githubusercontent.com/sarathsp06/sparrow/main/client/verify/python/sparrow_verify.pypip install cryptography # only for Ed25519 (v1a)# FastAPIfrom fastapi import FastAPI, Request, Responsefrom sparrow_verify import SignatureVerificationError, verify_hmac
app = FastAPI()
@app.post("/webhook")async def webhook(request: Request): body = await request.body() # raw bytes try: verify_hmac(body, request.headers, WEBHOOK_SECRET) # Ed25519 instead: verify_ed25519(body, request.headers, SIGNING_PUBLIC_KEY_HEX) except SignatureVerificationError: return Response(status_code=401) return Response(status_code=200)Flask: verify_hmac(request.get_data(), request.headers, WEBHOOK_SECRET).
curl -O https://raw.githubusercontent.com/sarathsp06/sparrow/main/client/verify/js/sparrow-verify.ts// Express (Node.js >= 16 or Bun; no dependencies beyond node:crypto)import express from "express";import { verifyHmac, SignatureVerificationError } from "./sparrow-verify";
const app = express();
// express.raw keeps the body as a Buffer: don't use express.json() on this route.app.post("/webhook", express.raw({ type: "*/*" }), (req, res) => { try { verifyHmac(req.body, req.headers, process.env.WEBHOOK_SECRET!); // Ed25519 instead: verifyEd25519(req.body, req.headers, signingPublicKeyHex) } catch (err) { if (err instanceof SignatureVerificationError) return res.sendStatus(401); throw err; } res.sendStatus(200);});curl -O https://raw.githubusercontent.com/sarathsp06/sparrow/main/client/verify/java/SparrowVerify.javaJava 15+ (Ed25519 comes from the JDK). The file declares package sparrow.verify;,
so place it under sparrow/verify/ or change the package line.
// Spring Bootimport java.util.Map;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.*;import sparrow.verify.SparrowVerify;import sparrow.verify.SparrowVerify.SignatureVerificationException;
@RestControllerclass WebhookController { @PostMapping("/webhook") ResponseEntity<Void> webhook(@RequestBody byte[] body, // raw bytes @RequestHeader Map<String, String> headers) { try { SparrowVerify.verifyHmac(body, headers, webhookSecret); // Ed25519 instead: SparrowVerify.verifyEd25519(body, headers, signingPublicKeyHex); } catch (SignatureVerificationException e) { return ResponseEntity.status(401).build(); } return ResponseEntity.ok().build(); }}curl -O https://raw.githubusercontent.com/sarathsp06/sparrow/main/client/verify/kotlin/SparrowVerify.ktJDK 15+. It’s self-contained and doesn’t need the Java file. Callable from Java too
(@JvmStatic).
// Ktorimport io.ktor.http.HttpStatusCodeimport io.ktor.server.request.receiveimport io.ktor.server.response.respondimport io.ktor.server.routing.postimport sparrow.verify.SparrowVerify
post("/webhook") { val body = call.receive<ByteArray>() // raw bytes val headers = call.request.headers.entries().associate { it.key to it.value.first() } try { SparrowVerify.verifyHmac(body, headers, webhookSecret) // Ed25519 instead: SparrowVerify.verifyEd25519(body, headers, signingPublicKeyHex) } catch (e: SparrowVerify.SignatureVerificationException) { return@post call.respond(HttpStatusCode.Unauthorized) } call.respond(HttpStatusCode.OK)}curl -O https://raw.githubusercontent.com/sarathsp06/sparrow/main/client/verify/ruby/sparrow_verify.rbOnly needs the standard library (openssl). Ed25519 needs OpenSSL 1.1.1+. It
accepts Rack-style header names (HTTP_WEBHOOK_ID), so request.headers and
request.env work as they are.
# Railsrequire_relative "sparrow_verify"
class WebhooksController < ApplicationController skip_before_action :verify_authenticity_token
def receive SparrowVerify.verify_hmac(request.raw_post, request.headers, ENV.fetch("WEBHOOK_SECRET")) # Ed25519 instead: SparrowVerify.verify_ed25519(request.raw_post, request.headers, public_key_hex) head :ok rescue SparrowVerify::SignatureVerificationError head :unauthorized endendSinatra: SparrowVerify.verify_hmac(request.body.read, request.env, secret).
curl -O https://raw.githubusercontent.com/sarathsp06/sparrow/main/client/verify/php/SparrowVerify.phpPHP 8.1+, no Composer needed. Ed25519 uses the bundled sodium extension.
// Laravel (routes/api.php)use Illuminate\Http\Request;use Sparrow\Verify\SignatureVerificationException;use Sparrow\Verify\SparrowVerify;
require_once base_path('lib/SparrowVerify.php');
Route::post('/webhook', function (Request $request) { try { // getContent() is the raw body; don't use $request->json(). SparrowVerify::verifyHmac($request->getContent(), $request->headers->all(), config('services.sparrow.secret')); // Ed25519 instead: SparrowVerify::verifyEd25519($body, $headers, $publicKeyHex); } catch (SignatureVerificationException $e) { return response('', 401); } return response('', 200);});Plain PHP: SparrowVerify::verifyHmac(file_get_contents('php://input'), getallheaders(), $secret);
curl -o src/sparrow_verify.rs https://raw.githubusercontent.com/sarathsp06/sparrow/main/client/verify/rust/sparrow_verify.rscargo add hmac@0.12 sha2@0.10 base64@0.22 ed25519-dalek@2// Axum. In main.rs or lib.rs: mod sparrow_verify;use axum::{body::Bytes, http::{HeaderMap, StatusCode}};
async fn webhook(headers: HeaderMap, body: Bytes) -> StatusCode { // Bytes is the raw body; &HeaderMap works directly (so does &HashMap<String, String>). match crate::sparrow_verify::verify_hmac(&body, &headers, &webhook_secret()) { // Ed25519 instead: sparrow_verify::verify_ed25519(&body, &headers, &public_key_hex) Ok(()) => StatusCode::OK, Err(_) => StatusCode::UNAUTHORIZED, }}Custom clock or tolerance: verify_hmac_at(payload, headers, secret, tolerance_secs, now_unix_secs).
curl -O https://raw.githubusercontent.com/sarathsp06/sparrow/main/client/verify/elixir/sparrow_verify.exOnly needs :crypto (OTP 25+). Functions return {:ok, :verified} or {:error, reason};
the ! variants raise instead.
# Phoenix: Plug.Parsers consumes the body, so keep a copy for verification.defmodule MyApp.CacheBodyReader do def read_body(conn, opts) do {:ok, body, conn} = Plug.Conn.read_body(conn, opts) {:ok, body, Plug.Conn.assign(conn, :raw_body, body)} endend
# endpoint.explug Plug.Parsers, parsers: [:json], pass: ["application/json"], body_reader: {MyApp.CacheBodyReader, :read_body, []}, json_decoder: Jason
# controllerdef webhook(conn, _params) do case SparrowVerify.verify_hmac(conn.assigns.raw_body, conn.req_headers, secret()) do # Ed25519 instead: SparrowVerify.verify_ed25519(raw_body, conn.req_headers, public_key_hex) {:ok, :verified} -> send_resp(conn, 200, "") {:error, _reason} -> send_resp(conn, 401, "") endendThe authoritative way to check a signature by hand, independent of any library,
and handy when a sample disagrees with what you expect. Any Standard Webhooks
library can also verify v1 signatures, as long as the secret is in whsec_
format (Sparrow’s default).
# Inputs: the three headers, the raw body in body.json, and the whsec_ secret.key_hex=$(printf %s "${SECRET#whsec_}" | base64 -d | xxd -p -c 256)printf '%s.%s.' "$WEBHOOK_ID" "$WEBHOOK_TIMESTAMP" | cat - body.json \ | openssl dgst -sha256 -mac HMAC -macopt hexkey:"$key_hex" -binary | base64# Compare with each "v1,<base64>" entry in webhook-signature, and check the# timestamp is within 5 minutes of now.To write a verifier for another language, port
pkg/signature/signature.go.
Its test cases in
pkg/signature/testdata/vectors.json,
generated by the server’s signer, make a good checklist.
Testing your receiver
Section titled “Testing your receiver”sparrow listenregisters a temporary webhook and prints each delivery with its signature check, which makes it an easy way to see real signed requests (see the CLI guide). Point it at your receiver with--forwardto confirm a sample accepts genuine deliveries and rejects tampered ones.- Each helper takes an optional clock and tolerance (
now/nowSeconds/verify_hmac_at/signature.Verifier), so you can unit-test your handler with a fixed request instead of a live one.