Geldstuck-Signature header against the raw request body - no wrapper library required. For the full algorithm and rationale, see signatures.
Always verify against the raw body bytes before parsing JSON. Any whitespace or key-order change will break the HMAC.
Reusable verify function
Everything below builds on this tiny helper. Copy it into your codebase.import crypto from "crypto";
export function verifyWebhook(
rawBody: string | Buffer,
header: string,
secret: string,
toleranceSec = 300,
): unknown {
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=") as [string, string]),
);
const timestamp = Number(parts.t);
const received = parts.v1;
if (!timestamp || !received) throw new Error("Malformed signature");
if (Math.floor(Date.now() / 1000) - timestamp > toleranceSec) {
throw new Error("Timestamp outside tolerance");
}
const body = typeof rawBody === "string" ? rawBody : rawBody.toString("utf8");
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${body}`, "utf8")
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(received);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw new Error("Invalid signature");
}
return JSON.parse(body);
}
Express (Node.js)
import express from "express";
import { verifyWebhook } from "./verify";
const app = express();
app.post(
"/webhooks/geldstuck",
express.raw({ type: "application/json" }), // raw, not json
(req, res) => {
let event;
try {
event = verifyWebhook(
req.body,
req.headers["geldstuck-signature"] as string,
process.env.GELDSTUCK_WEBHOOK_SECRET!,
);
} catch {
return res.status(400).send("Invalid signature");
}
switch ((event as any).type) {
case "kyc.completed": /* ... */ break;
case "transaction.status.changed": /* ... */ break;
default: console.log("Unhandled:", (event as any).type);
}
res.sendStatus(200);
},
);
Next.js (App Router)
// app/api/webhooks/route.ts
import { NextResponse } from "next/server";
import { verifyWebhook } from "@/lib/verify";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get("geldstuck-signature")!;
let event;
try {
event = verifyWebhook(body, sig, process.env.GELDSTUCK_WEBHOOK_SECRET!);
} catch {
return new NextResponse("Invalid signature", { status: 400 });
}
await enqueue(event);
return NextResponse.json({ received: true });
}
Flask (Python)
from flask import Flask, request
from verify import verify_webhook
import os
app = Flask(__name__)
@app.post("/webhooks/geldstuck")
def handle():
try:
event = verify_webhook(
request.data,
request.headers["Geldstuck-Signature"],
os.environ["GELDSTUCK_WEBHOOK_SECRET"],
)
except ValueError:
return "Invalid signature", 400
if event["type"] == "kyc.completed":
...
return "", 200
FastAPI (Python)
from fastapi import FastAPI, Header, Request, HTTPException
from verify import verify_webhook
import os
app = FastAPI()
@app.post("/webhooks/geldstuck")
async def handle(
request: Request,
geldstuck_signature: str = Header(...),
):
payload = await request.body()
try:
event = verify_webhook(
payload,
geldstuck_signature,
os.environ["GELDSTUCK_WEBHOOK_SECRET"],
)
except ValueError:
raise HTTPException(400, "Invalid signature")
return {"received": True}
Gin (Go)
func HandleWebhook(c *gin.Context) {
body, _ := io.ReadAll(c.Request.Body)
sig := c.GetHeader("Geldstuck-Signature")
secret := os.Getenv("GELDSTUCK_WEBHOOK_SECRET")
event, err := VerifyWebhook(body, sig, secret)
if err != nil {
c.AbortWithStatus(http.StatusBadRequest)
return
}
switch event["type"] {
case "kyc.completed":
// ...
}
c.Status(http.StatusOK)
}
VerifyWebhook is the Go equivalent of the helper in signatures.
Rails (Ruby)
# config/routes.rb
post "/webhooks/geldstuck", to: "webhooks#geldstuck"
# app/controllers/webhooks_controller.rb
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def geldstuck
payload = request.body.read
sig = request.headers["Geldstuck-Signature"]
event = verify_webhook(payload, sig, ENV.fetch("GELDSTUCK_WEBHOOK_SECRET"))
case event["type"]
when "kyc.completed" then KycMailer.completed(event["data"]).deliver_later
end
head :ok
rescue => e
head :bad_request
end
end
Cloudflare Workers
export default {
async fetch(req: Request, env: Env) {
const body = await req.text();
const sig = req.headers.get("geldstuck-signature")!;
try {
const event = verifyWebhook(body, sig, env.GELDSTUCK_WEBHOOK_SECRET);
await env.EVENT_QUEUE.send(event);
} catch {
return new Response("Invalid signature", { status: 400 });
}
return new Response("ok");
},
};
Webhook verification is the one place you should not get creative. Copy the reference helper, add tests, move on.