Docs menuWebhook (any stack)

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.

Cleanup

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

  1. Write the receiver

    An HTTPS endpoint on a public host (port 443 or 8443). Start from the Node example below.

  2. Add it to Flowproof

    Terminal
    read -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 the FLOWPROOF_SECRET environment variable). If you lose it, remove the connection and add it again.

  3. Test, preview and arm

    Use the requests on the Cleanup page. The test and the preview both send your endpoint a preview request; your receipt is checked before you can arm.

The request

What Flowproof sends
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
}
FieldMeaning
sessionThe run, e.g. s7k2ab. Echo it back in the receipt.
projectYour project name. Refuse requests for any other project.
addressesThe 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.
requestIdThe same on every retry of one cleanup. Save your receipt under it and replay that receipt if it comes again.
issuedAtUnix 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:

  1. Read the raw request body, as bytes, before parsing it.
  2. Compute HMAC-SHA256, keyed with your secret, over the timestamp, a full stop, and the raw body: `${t}.${rawBody}`. Write it as lowercase hex.
  3. Compare it with v1 in constant time.
  4. Reject the request if t is more than 300 seconds from now.

It’s the same scheme as Stripe’s webhooks. Check your code against this known answer:

Test vector
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=48d4153c110a737b17a423e0711dac0d08b375b3c44f5cf85dd3a24ea94d4bd6

The receipt

Answer with HTTP 200 and JSON like this:

Your response
{
  "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, one not_found item with an empty id.
  • action is deleted, would_delete (preview), anonymized, not_found or skipped. not_found and skipped need a reason.
  • Put failures in errors as { "code": "...", "message": "..." } rather than answering with an error page.
  • session, requestId and mode must 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
// 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.