PromptFloe Developer Docs
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 of t.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