Webhook (any stack)
For any stack Flowproof has no built-in connection for: Flowproof sends your endpoint a signed list of addresses, your code deletes those records, and it answers with a receipt.
Your endpoint holds your credentials; Flowproof only ever sees the receipt. You decide what “delete” means (hard delete, soft delete, anonymise), as long as you act only on the addresses in the request.
Set it up
Write the receiver
An HTTPS endpoint on a public host (port 443 or 8443). Start from the Node example below.
Add it to Flowproof
Terminalread -rs FLOWPROOF_API_KEY # paste your fp_ key, press Enter (nothing is shown) export FLOWPROOF_API_KEY curl -s -X POST https://mcp.flowproof.dev/api/projects/acme/adapters \ -H "Authorization: Bearer $FLOWPROOF_API_KEY" \ -H "Content-Type: application/json" \ -d '{"label": "My app", "config": {"system": "webhook", "endpointUrl": "https://app.example.com/flowproof/teardown"}}'The reply contains a
secret, shown once. Store it as your endpoint’s signing secret (for example theFLOWPROOF_SECRETenvironment variable). If you lose it, remove the connection and add it again.Test, preview and arm
Use the requests on the Cleanup page. The test and the preview both send your endpoint a
previewrequest; your receipt is checked before you can arm.
The request
POST /flowproof/teardown HTTP/1.1
Content-Type: application/json
X-Flowproof-Signature: t=1758326400,v1=<64 hex characters>
X-Flowproof-Session: s7k2ab
X-Flowproof-Request-Id: td_01HZX9K3
{
"session": "s7k2ab",
"project": "acme",
"addresses": ["acme.s7k2ab.alice@flowproof.dev", "acme.s7k2ab.bob@flowproof.dev"],
"mode": "delete",
"requestId": "td_01HZX9K3",
"issuedAt": 1758326400
}| Field | Meaning |
|---|---|
session | The run, e.g. s7k2ab. Echo it back in the receipt. |
project | Your project name. Refuse requests for any other project. |
addresses | The exact addresses the run created. Act on these and nothing else: never a pattern or a domain. |
mode | "preview" (report what you would delete; change nothing) or "delete". Anything else, or a missing value: refuse. |
requestId | The same on every retry of one cleanup. Save your receipt under it and replay that receipt if it comes again. |
issuedAt | Unix seconds. |
The X-Flowproof-Session and X-Flowproof-Request-Id headers repeat the body’s values for convenience. Trust the signed body, not the headers. Flowproof waits up to 15 seconds for an answer and never follows a redirect.
Checking the signature
X-Flowproof-Signature looks like t=1758326400,v1=5f2b…. To check it:
- Read the raw request body, as bytes, before parsing it.
- Compute HMAC-SHA256, keyed with your secret, over the timestamp, a full stop, and the raw body:
`${t}.${rawBody}`. Write it as lowercase hex. - Compare it with
v1in constant time. - Reject the request if
tis more than 300 seconds from now.
It’s the same scheme as Stripe’s webhooks. Check your code against this known answer:
secret 0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0
timestamp 1758326400
raw body {"session":"s7k2ab","project":"acme","addresses":["acme.s7k2ab.alice@flowproof.dev"],"mode":"preview","requestId":"td_example","issuedAt":1758326400}
signed "1758326400." followed by the raw body
header X-Flowproof-Signature: t=1758326400,v1=48d4153c110a737b17a423e0711dac0d08b375b3c44f5cf85dd3a24ea94d4bd6The receipt
Answer with HTTP 200 and JSON like this:
{
"system": "webhook",
"systemLabel": "My app",
"session": "s7k2ab",
"requestId": "td_01HZX9K3",
"mode": "delete",
"items": [
{
"type": "user",
"id": "8412",
"label": "acme.s7k2ab.alice@flowproof.dev",
"action": "deleted",
"matchedAddress": "acme.s7k2ab.alice@flowproof.dev"
},
{
"type": "user",
"id": "",
"label": "acme.s7k2ab.bob@flowproof.dev",
"action": "not_found",
"reason": "No user has this address.",
"matchedAddress": "acme.s7k2ab.bob@flowproof.dev"
}
],
"errors": [],
"durationMs": 42
}- One item per record, with the address it matched in
matchedAddress. For an address with no record, onenot_founditem with an emptyid. actionisdeleted,would_delete(preview),anonymized,not_foundorskipped.not_foundandskippedneed areason.- Put failures in
errorsas{ "code": "...", "message": "..." }rather than answering with an error page. session,requestIdandmodemust match the request.
A minimal receiver in Node
No dependencies, Node 18 or later. Replace the two functions at the top with your own database calls. Until you do, they throw, which shows up as errors in the preview receipt, so an unfinished receiver can’t be armed.
// flowproof-teardown.mjs -- Node 18+, no dependencies.
// Run with: FLOWPROOF_SECRET=<the secret shown when you added the adapter> node flowproof-teardown.mjs
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.FLOWPROOF_SECRET;
const PROJECT = 'acme'; // your Flowproof project slug
const TOLERANCE_SECONDS = 300; // reject requests more than 5 minutes old
const receipts = new Map(); // requestId:mode -> receipt. Use your database in production.
// ---- Replace these two with your own data access. Match the EXACT email only:
// ---- never LIKE, never a domain, never an "is test user" flag.
async function findUsersByEmail(email) {
// e.g. return db.query('select id, email from users where lower(email) = $1', [email]);
throw new Error('findUsersByEmail is not implemented yet');
}
async function deleteUser(id) {
// e.g. await db.query('delete from users where id = $1', [id]);
throw new Error('deleteUser is not implemented yet');
}
function signatureIsValid(rawBody, header) {
if (!SECRET || typeof header !== 'string') return false;
let t = null;
let v1 = null;
for (const part of header.split(',')) {
const [key, value = ''] = part.split('=').map((s) => s.trim());
if (key === 't' && /^\d+$/.test(value)) t = Number(value);
if (key === 'v1') v1 = value;
}
if (t === null || !v1) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', SECRET).update(`${t}.${rawBody}`).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(v1);
return a.length === b.length && timingSafeEqual(a, b);
}
function send(res, status, body) {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
}
createServer(async (req, res) => {
if (req.method !== 'POST' || req.url !== '/flowproof/teardown') return send(res, 404, { error: 'not_found' });
// Verify the signature over the RAW bytes, before parsing anything.
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const rawBody = Buffer.concat(chunks).toString('utf8');
if (!signatureIsValid(rawBody, req.headers['x-flowproof-signature'])) return send(res, 401, { error: 'bad_signature' });
let body;
try {
body = JSON.parse(rawBody);
} catch {
return send(res, 400, { error: 'bad_json' });
}
const { session, project, requestId, addresses, mode } = body;
// Fail closed: only the exact word "delete" may delete anything.
if (mode !== 'preview' && mode !== 'delete') return send(res, 400, { error: 'bad_mode' });
if (project !== PROJECT) return send(res, 403, { error: 'wrong_project' });
if (typeof requestId !== 'string' || !Array.isArray(addresses) || addresses.length === 0) {
return send(res, 400, { error: 'bad_request' });
}
// A retry of a request we already ran: answer with the original receipt.
const key = `${requestId}:${mode}`;
if (receipts.has(key)) return send(res, 200, receipts.get(key));
const started = Date.now();
const items = [];
const errors = [];
for (const address of new Set(addresses.map((a) => String(a).toLowerCase()))) {
try {
const users = (await findUsersByEmail(address)).filter((u) => String(u.email).toLowerCase() === address);
if (users.length === 0) {
items.push({ type: 'user', id: '', label: address, action: 'not_found', reason: 'No user has this address.', matchedAddress: address });
}
for (const user of users) {
if (mode === 'delete') await deleteUser(user.id);
items.push({
type: 'user',
id: String(user.id),
label: address,
action: mode === 'delete' ? 'deleted' : 'would_delete',
matchedAddress: address,
});
}
} catch (error) {
errors.push({ code: 'lookup_or_delete_failed', message: String(error?.message ?? error) });
}
}
const receipt = { system: 'webhook', systemLabel: 'My app', session, requestId, mode, items, errors, durationMs: Date.now() - started };
receipts.set(key, receipt);
send(res, 200, receipt);
}).listen(3001, () => console.log('Flowproof teardown receiver on :3001'));For production, keep receipts in your database (for at least a day) instead of in memory, and run it behind your usual HTTPS.