Architecture
Webhook Events
Every webhook handler verifies a signature over the raw body before trusting a byte, rejects stale timestamps, and fails closed when its secret is unset. This page lists the inbound handlers and shows how to verify events Furlpay sends you.
Inbound handlers
| Method | Path | Description |
|---|---|---|
| POST | /api/webhooks/stripe | Stripe events — stripe-signature verified via constructEvent |
| POST | /api/webhooks/sumsub | Sumsub KYC outcomes — HMAC digest over the raw body |
| POST | /api/webhooks/persona | Persona inquiry outcomes — persona-signature HMAC |
| POST | /api/webhooks/wise | Wise transfer state changes — signature verified |
| POST | /api/webhooks/bridge | Bridge fiat rail events — signature verified |
| POST | /api/webhooks/marqeta | Card processor callbacks (JIT funding, auth events) |
| POST | /api/webhooks/card-auth | 3DS2 card authentication events — endpoint secret |
- Signatures are computed over the raw request body — parse only after verification.
- Comparisons are constant-time; timestamped schemes reject events older than the tolerance window.
- A handler whose secret is not configured refuses the event (fail closed) rather than accepting unsigned input.
- Webhooks are exempt from the mutating-request IP rate-limit baseline (providers batch deliveries from few IPs) — the signature is the gate.
Verifying events from Furlpay
Events delivered to your endpoint carry a Furlpay-Signature header: an HMAC-SHA256 over timestamp + "." + rawBody with your endpoint secret. Verify before trusting:
typescriptapp/api/webhooks/furlpay/route.ts
import crypto from "crypto";
export async function POST(req: Request) {
const raw = await req.text(); // RAW body — do not parse first
const sig = req.headers.get("furlpay-signature") ?? "";
const ts = req.headers.get("furlpay-timestamp") ?? "";
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
return new Response("stale", { status: 400 }); // replay window: 5 minutes
}
const expected = crypto
.createHmac("sha256", process.env.FURLPAY_WEBHOOK_SECRET!)
.update(`${ts}.${raw}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(sig);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return new Response("bad signature", { status: 401 });
}
const event = JSON.parse(raw); // safe to parse now
// handle event.type ...
return new Response("ok");
}Local development
Forward live events to localhost with the CLI — it prints the signing secret to verify against:
bash
furlpay listen --forward-to localhost:3000/api/webhooks/furlpayNever credit balances from an unverified event
A webhook body is attacker-controllable input until the signature passes. Verify first, act second, and make handlers idempotent — providers redeliver.
Did this page help?
Edit this page on GitHub