Webhooks
Signature verification
Every webhook delivery includes an X-PromptFloe-Signature header. Verify it before trusting the body — anyone with your public URL could otherwise impersonate us.
#Header format
The signature header is a comma-delimited list of timestamp + signature pairs:
X-PromptFloe-Signature
t=1730000000,v1=8f3a...t— Unix epoch seconds when we sent the delivery.v1— hex-encoded HMAC-SHA256 oft.body, signed with your endpoint's secret.
#Verification
import crypto from 'node:crypto';
function verify(rawBody: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('=') as [string, string]),
);
const t = parts.t;
const sig = parts.v1;
if (!t || !sig) return false;
// Reject deliveries older than 5 minutes
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(sig, 'hex'),
);
}#Use the raw body
Verify against the raw request body — not a re-serialized JSON. Frameworks that auto-parse JSON often re-encode it (sorting keys, normalizing whitespace) which breaks the signature.
// app/webhooks/promptfloe/route.ts
export async function POST(req: Request) {
const rawBody = await req.text(); // raw, not .json()
const sig = req.headers.get('x-promptfloe-signature') ?? '';
if (!verify(rawBody, sig, process.env.PROMPTFLOE_WEBHOOK_SECRET!)) {
return new Response('invalid signature', { status: 401 });
}
const event = JSON.parse(rawBody);
// handle event...
return new Response('ok');
}#Secret rotation
Generate a new secret from the dashboard or via POST /v1/webhooks/:id/rotate-secret. The old secret remains valid for 24 hours so you can roll out the new one without dropping deliveries.
#Where to go next
PromptFloe developer docs