Docs menuTesting scheduled emails

Testing scheduled emails

Some emails wait for time: the trial ends in three days, the weekly digest, the 30-day check-in. Your agent can’t wait days. A test hook is one small endpoint in your app that sends one of those emails now, for one of Flowproof’s test users, and for nobody else.

Guide

The problem

The email map lists every email your app can send. The ones a person triggers, your agent can test by doing what the person does. The ones a clock triggers (a cron job, a queue delay, a Stripe trial ending) would need your agent to wait days, and faking the clock or editing your database to hurry them along is exactly what it must never do.

How it works

You add one endpoint to your app and name the things it can do, like send_30_day_checkin or run_weekly_digest. Each one does, for one user, what your scheduled job does for everyone whose day has come. When your agent reaches that email, it asks Flowproof to call the endpoint for the test user it just made. Your app checks three things before it does anything:

  1. Flowproof sent it. Every call is signed with the hook’s own secret, and older than five minutes is refused.
  2. It is one of your project’s test addresses. Like drawright.s7k2ab.owner@flowproof.dev. A real customer’s email can never pass this check, whatever anyone signs or says.
  3. Flowproof confirms this exact call. Your app asks Flowproof with its verification token (the same one test checkout uses). Flowproof says yes once per call, only for a test user in a run that is open right now. A copied, replayed or forged request gets no.

Then it finds that one user by that exact address and runs the action for them.

Example: DrawRight’s 30-day check-in

DrawRight sends each owner a check-in email 30 days after they sign up, from a nightly job. Its endpoint declares one action, send_30_day_checkin, which calls the same function the nightly job calls, for one owner. This is what the agent does with it:

One run
get_email_map     checkin-30-day-owner: "30-day check-in" (a nightly cron, 30 days after signup)
list_test_hooks   send_30_day_checkin — The 30-day check-in email to the owner
start_session     project "drawright"
new_address       local "owner"          drawright.s7k2ab.owner@flowproof.dev
  ...the agent signs the owner up in the app...
mark_step         "triggered day-30 check-in for owner"
run_test_hook     session s7k2ab, drawright.s7k2ab.owner@flowproof.dev, "send_30_day_checkin"   → ran
wait_for_email    the owner's address, map_key "checkin-30-day-owner"
  ...extract, follow the link, record_outcome, as for any email...
end_session       the owner is deleted from DrawRight

The agent marks the step first, as for any action, so the report measures how long the email took from the moment it was asked for. The hook call appears on the run’s timeline as a step of its own, and the email counts for the map entry like any other.

Set it up

  1. Add the endpoint to your app

    Paste the reference implementation for your stack from below, and write one function per action. Each one receives the test user; call the code your scheduled job already runs, for that user only.

  2. Add the hook in your dashboard

    On Projects, under Test hooks, enter the endpoint’s address and the actions, one per line (send_30_day_checkin — The 30-day check-in email to the owner). Copy the signing secret: it is shown once.

  3. Give your app three settings

    On your server, never in front-end code. The verification token comes from Test checkout on the same page.

    Server environment
    FLOWPROOF_PROJECT=drawright
    FLOWPROOF_HOOK_SECRET=<the secret shown when you added the hook>
    FLOWPROOF_VERIFY_TOKEN=fpv_<a verification token for this project>
  4. Test, then arm

    Deploy, then press Test. Flowproof checks your endpoint refuses a request with the wrong signature, answers a signed ping after confirming it, and refuses a call Flowproof never confirmed. If all three pass, press Arm within the hour. An endpoint that acts on a request it should have refused is switched off.

What your endpoint receives

From Flowproof
POST https://app.drawright.com/api/flowproof-hook
X-Flowproof-Signature: t=1758326400,v1=<hex HMAC-SHA256 of "1758326400.<raw body>">
X-Flowproof-Hook-Call: hkc_…
Content-Type: application/json

{
  "v": 1,
  "kind": "flowproof.test_hook",
  "callId": "hkc_…",
  "project": "drawright",
  "session": "s7k2ab",
  "address": "drawright.s7k2ab.owner@flowproof.dev",
  "action": "send_30_day_checkin",
  "params": {},
  "issuedAt": 1758326400
}

And the question it asks before acting (step 3 above):

To Flowproof
POST https://mcp.flowproof.dev/api/test-users/verify
Authorization: Bearer fpv_…
Content-Type: application/json

{ "email": "drawright.s7k2ab.owner@flowproof.dev", "hookCall": "hkc_…", "action": "send_30_day_checkin" }

→ 200 { "testUser": true, "session": "s7k2ab", ... }   the one yes; run the action
→ 200 { "testUser": false }                            anything else; refuse
Your answerWhen
200 { "ok": true, "action": "..." }You ran the action for that user (or answered the ping).
401 bad_signatureThe signature is missing, wrong or older than five minutes.
400 bad_requestThe body isn’t a Flowproof hook request.
403 not_a_test_addressThe address isn’t one of your project’s test addresses in that run.
403 not_a_live_test_userFlowproof didn’t say yes, or couldn’t be reached.
404 unknown_actionYour code doesn’t handle that action.
404 user_not_foundNo account has exactly that email.
500 action_failedYour action threw.

The body is always { "ok": false, "error": "<code>" } on a refusal; the connection test reads the code. Flowproof calls once, waits at most 20 seconds, and never retries: the inbox says whether the email went.

Reference implementations

Next.js (App Router)

Two files, no dependencies. Replace the two functions marked YOUR APP.

app/api/flowproof-hook/route.ts
/**
 * Flowproof test hook for a Next.js (App Router) app: POST /api/flowproof-hook
 *
 * Paste this file at app/api/flowproof-hook/route.ts and lib/flowproof-hook.ts
 * next to it (at lib/flowproof-hook.ts), then:
 *
 *   1. In the Flowproof dashboard (Projects, then Test hooks), add a hook with
 *      https://<your app>/api/flowproof-hook and the actions below. Copy the
 *      signing secret it shows once.
 *   2. Set three SERVER environment variables (never NEXT_PUBLIC_):
 *        FLOWPROOF_PROJECT       your project slug, e.g. drawright
 *        FLOWPROOF_HOOK_SECRET   the signing secret from step 1
 *        FLOWPROOF_VERIFY_TOKEN  the project's verification token (fpv_...), from
 *                                Projects, then Test checkout
 *   3. Deploy, then press Test and Arm in the dashboard.
 *
 * Without all three variables the route refuses every request (503). To turn
 * it off, unset FLOWPROOF_HOOK_SECRET or disarm the hook in the dashboard.
 *
 * The example is DrawRight's: a construction-draw app whose owners get a
 * check-in email 30 days after signup, from a nightly cron. Replace the two
 * functions marked YOUR APP with your own code. Each action acts on the ONE
 * user it is handed -- that is the rule this route cannot check for you.
 */

import { createFlowproofHook } from '@/lib/flowproof-hook';

// Signature checks need the raw body and a fresh answer every time.
export const dynamic = 'force-dynamic';

interface User {
  id: string;
  email: string;
  name: string | null;
}

// YOUR APP: find a user by exact, lowercase email. With Prisma, for example:
//   return prisma.user.findUnique({ where: { email }, select: { id: true, email: true, name: true } });
async function findUserByEmail(email: string): Promise<User | null> {
  void email;
  return null;
}

// YOUR APP: the function your cron already calls for each user whose day has
// come. Called here for one user, now.
async function sendThirtyDayCheckin(user: User): Promise<void> {
  void user;
  throw new Error('Wire sendThirtyDayCheckin to the code your 30-day cron runs for one user.');
}

export const POST = createFlowproofHook<User>({
  project: process.env.FLOWPROOF_PROJECT ?? '',
  secret: process.env.FLOWPROOF_HOOK_SECRET ?? '',
  verifyToken: process.env.FLOWPROOF_VERIFY_TOKEN ?? '',
  findUser: findUserByEmail,
  actions: {
    // Declared in the dashboard as: send_30_day_checkin -- "The 30-day check-in email to the owner"
    async send_30_day_checkin(user) {
      await sendThirtyDayCheckin(user);
      return { sent: '30-day check-in' };
    },
  },
});
lib/flowproof-hook.ts
/**
 * Flowproof test hook -- the handler behind app/api/flowproof-hook/route.ts.
 *
 * Copy this file into your app as lib/flowproof-hook.ts. It has no
 * dependencies: Web Crypto and fetch only, so it runs on the Node.js and Edge
 * runtimes alike.
 *
 * WHAT IT IS FOR. Some of your emails are sent by a clock: the trial ends in
 * three days, the weekly digest, the 30-day check-in. Flowproof's agent cannot
 * wait three days, so it asks this endpoint to do now what your app would do
 * then -- for one Flowproof test user, and nobody else.
 *
 * WHAT IT REFUSES, IN ORDER (https://www.flowproof.dev/docs/test-hooks):
 *
 *   1. A request not signed with FLOWPROOF_HOOK_SECRET in the last five minutes.  401 bad_signature
 *   2. A body that is not exactly a Flowproof hook request.                      400 bad_request
 *   3. An address that is not a Flowproof test address of YOUR project, in the
 *      request's own session. A real customer's email can never pass this,
 *      whatever anyone signs or says.                                           403 not_a_test_address
 *   4. A call Flowproof does not confirm. This handler asks Flowproof, with
 *      your verification token (FLOWPROOF_VERIFY_TOKEN, fpv_...), whether this
 *      exact call is real. Flowproof says yes once per call, only for a test
 *      user in a session that is running right now. A replayed request, a
 *      forged one, or one for any other address gets a no. Anything but an
 *      explicit yes -- a timeout, an error, a 500 -- is a no.                     403 not_a_live_test_user
 *   5. An action you did not register below.                                   404 unknown_action
 *   6. An address with no account in your app.                                 404 user_not_found
 *
 * Then it runs your action for that one user and answers { ok: true }.
 *
 * YOUR ACTIONS MUST ACT ONLY ON THE USER THEY ARE GIVEN. "run_weekly_digest"
 * means "build and send this user's digest now", never "run the digest job".
 * That is the one rule this file cannot enforce for you.
 *
 * Pinned to Flowproof's contract (packages/adapters/src/hooks.ts) by the
 * shared conformance suite in adapters/nextjs/test.
 */

export type HookParams = Record<string, string | number | boolean | null>;

export interface FlowproofHookOptions<User> {
  /** Your Flowproof project slug, e.g. "drawright". */
  project: string;
  /** FLOWPROOF_HOOK_SECRET: shown once when you created the hook. */
  secret: string;
  /** FLOWPROOF_VERIFY_TOKEN: the project's verification token (fpv_...), the same one test checkout uses. */
  verifyToken: string;
  /** Your user, found by EXACT email (lowercase). Return null when there is none. */
  findUser(email: string): Promise<User | null>;
  /** Your actions, by the names you declared in the Flowproof dashboard. Each acts on the one user it is given. */
  actions: Record<string, (user: User, params: HookParams) => Promise<unknown> | unknown>;
  /** Leave as is unless Flowproof tells you otherwise. */
  rootDomain?: string;
  layout?: 'flat' | 'subdomain';
  verifyUrl?: string;
  /** Tests only. */
  fetch?: (input: string, init: RequestInit) => Promise<Response>;
  now?: () => number;
  /** One line per request: outcome and action, never an address or a secret. */
  log?: (event: Record<string, unknown>) => void;
}

const PING = 'flowproof.ping';
const KIND = 'flowproof.test_hook';
const TOLERANCE_SECONDS = 300;
const MAX_BODY = 16 * 1024;
const VERIFY_TIMEOUT_MS = 5_000;
const CALL_ID = /^hkc_[A-Za-z0-9_-]{43}$/;
const ACTION = /^[a-z][a-z0-9_]{0,63}$/;
const PARAM_KEY = /^[a-z][a-z0-9_]{0,39}$/;
const TOKEN = /^fpv_[A-Za-z0-9_-]{32,128}$/;
// eslint-disable-next-line no-control-regex
const CONTROL = /[\u0000-\u001F\u007F\u0080-\u009F‎‏‪-‮⁦-⁩]/;

type Refusal = 'bad_signature' | 'bad_request' | 'not_a_test_address' | 'unknown_action' | 'not_a_live_test_user' | 'user_not_found' | 'action_failed' | 'misconfigured';
const STATUS: Record<Refusal, number> = {
  bad_signature: 401,
  bad_request: 400,
  not_a_test_address: 403,
  unknown_action: 404,
  not_a_live_test_user: 403,
  user_not_found: 404,
  action_failed: 500,
  misconfigured: 503,
};

interface HookRequest {
  callId: string;
  project: string;
  session: string;
  address: string;
  action: string;
  params: HookParams;
  issuedAt: number;
}

export function createFlowproofHook<User>(options: FlowproofHookOptions<User>): (request: Request) => Promise<Response> {
  const rootDomain = (options.rootDomain ?? 'flowproof.dev').toLowerCase();
  const layout = options.layout ?? 'flat';
  const verifyUrl = options.verifyUrl ?? 'https://mcp.flowproof.dev/api/test-users/verify';
  const doFetch = options.fetch ?? ((input: string, init: RequestInit) => fetch(input, init));
  const now = options.now ?? (() => Date.now());
  const log = options.log ?? ((event) => console.log(JSON.stringify({ fn: 'flowproof-hook', ...event })));

  return async (request) => {
    const refuse = (error: Refusal, action?: string): Response => {
      log({ outcome: error, ...(action === undefined ? {} : { action }) });
      return reply(STATUS[error], { ok: false, error });
    };

    if (request.method !== 'POST') return new Response(JSON.stringify({ ok: false, error: 'method_not_allowed' }), { status: 405, headers: { Allow: 'POST', 'content-type': 'application/json' } });

    // No secret, no token, or no project: refuse everything. There is no
    // "accept unsigned" mode, and no mode that skips asking Flowproof.
    if (options.secret === '' || !TOKEN.test(options.verifyToken) || !isProjectSlug(options.project)) return refuse('misconfigured');

    const raw = await request.text();
    if (raw.length > MAX_BODY) return refuse('bad_request');

    // 1. The signature, over the raw body exactly as sent.
    const nowSeconds = Math.floor(now() / 1000);
    if (!(await signatureValid(raw, request.headers.get('x-flowproof-signature') ?? '', options.secret, nowSeconds))) {
      return refuse('bad_signature');
    }

    // 2. The body.
    const hook = parseBody(raw, nowSeconds);
    if (hook === null) return refuse('bad_request');

    // 3. A Flowproof test address of THIS project, in the request's session.
    const parsed = parseAddress(hook.address, rootDomain, layout);
    if (hook.project !== options.project || parsed === null || parsed.project !== options.project || parsed.session !== hook.session) {
      return refuse('not_a_test_address', hook.action);
    }

    // 4. Flowproof confirms this exact call, once.
    if (!(await confirmed(doFetch, verifyUrl, options.verifyToken, parsed.address, hook))) {
      return refuse('not_a_live_test_user', hook.action);
    }

    // 5. The ping touches nobody.
    if (hook.action === PING) {
      log({ outcome: 'ok', action: PING });
      return reply(200, { ok: true, action: PING, result: { pong: true } });
    }
    if (!Object.prototype.hasOwnProperty.call(options.actions, hook.action)) return refuse('unknown_action', hook.action);
    const run = options.actions[hook.action];
    if (typeof run !== 'function') return refuse('unknown_action', hook.action);

    // 6. The one user, found by that exact address.
    const user = await options.findUser(parsed.address);
    if (user === null || user === undefined) return refuse('user_not_found', hook.action);
    const email = (user as { email?: unknown }).email;
    if (typeof email === 'string' && email.trim().toLowerCase() !== parsed.address) {
      // findUser matched something other than the exact address. Never act on it.
      return refuse('user_not_found', hook.action);
    }

    try {
      const result = await run(user, hook.params);
      log({ outcome: 'ok', action: hook.action });
      return reply(200, { ok: true, action: hook.action, ...(result === undefined ? {} : { result }) });
    } catch (error) {
      log({ outcome: 'action_failed', action: hook.action, error: error instanceof Error ? error.name : typeof error });
      return reply(500, { ok: false, error: 'action_failed', message: 'The action threw; see the app\'s logs.' });
    }
  };
}

async function confirmed(
  doFetch: (input: string, init: RequestInit) => Promise<Response>,
  url: string,
  token: string,
  address: string,
  hook: HookRequest,
): Promise<boolean> {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), VERIFY_TIMEOUT_MS);
  try {
    const response = await doFetch(url, {
      method: 'POST',
      redirect: 'error',
      signal: controller.signal,
      headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json' },
      body: JSON.stringify({ email: address, hookCall: hook.callId, action: hook.action }),
    });
    if (response.status !== 200) return false;
    const answer = (await response.json()) as Record<string, unknown> | null;
    return answer !== null && typeof answer === 'object' && answer['testUser'] === true && answer['session'] === hook.session;
  } catch {
    return false;
  } finally {
    clearTimeout(timer);
  }
}

function parseBody(raw: string, nowSeconds: number): HookRequest | null {
  let value: unknown;
  try {
    value = JSON.parse(raw);
  } catch {
    return null;
  }
  if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
  const r = value as Record<string, unknown>;
  if (r['v'] !== 1 || r['kind'] !== KIND) return null;
  const { callId, project, session, address, action, issuedAt } = r;
  if (typeof callId !== 'string' || !CALL_ID.test(callId)) return null;
  if (typeof project !== 'string' || typeof session !== 'string' || !isSessionId(session)) return null;
  if (typeof address !== 'string' || address.length > 320) return null;
  if (typeof action !== 'string' || (action !== PING && !ACTION.test(action))) return null;
  if (typeof issuedAt !== 'number' || !Number.isSafeInteger(issuedAt) || Math.abs(nowSeconds - issuedAt) > TOLERANCE_SECONDS) return null;
  const params = parseParams(r['params']);
  if (params === null) return null;
  return { callId, project, session, address, action, params, issuedAt };
}

function parseParams(raw: unknown): HookParams | null {
  if (raw === undefined || raw === null) return {};
  if (typeof raw !== 'object' || Array.isArray(raw)) return null;
  const entries = Object.entries(raw as Record<string, unknown>);
  if (entries.length > 20) return null;
  const out: HookParams = {};
  for (const [key, value] of entries) {
    if (!PARAM_KEY.test(key)) return null;
    if (value === null || typeof value === 'boolean') out[key] = value;
    else if (typeof value === 'number' && Number.isFinite(value)) out[key] = value;
    else if (typeof value === 'string' && value.length <= 500 && !CONTROL.test(value)) out[key] = value;
    else return null;
  }
  return out;
}

// -- Signature: HMAC-SHA256 over "<t>.<raw body>", header t=<unix>,v1=<hex> ---

async function signatureValid(raw: string, header: string, secret: string, nowSeconds: number): Promise<boolean> {
  let timestamp: number | null = null;
  let signature: string | null = null;
  for (const part of header.split(',')) {
    const eq = part.indexOf('=');
    if (eq < 0) continue;
    const key = part.slice(0, eq).trim();
    const value = part.slice(eq + 1).trim();
    if (key === 't' && /^\d+$/.test(value) && Number.isSafeInteger(Number(value))) timestamp = Number(value);
    else if (key === 'v1') signature = value;
  }
  if (timestamp === null || signature === null || Math.abs(nowSeconds - timestamp) > TOLERANCE_SECONDS) return false;
  const encoder = new TextEncoder();
  const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
  const mac = await crypto.subtle.sign('HMAC', key, encoder.encode(`${timestamp}.${raw}`));
  const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('');
  if (expected.length !== signature.length) return false;
  let diff = 0;
  for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ signature.charCodeAt(i);
  return diff === 0;
}

// -- Addresses (port of Flowproof's packages/core/src/address.ts) ------------

const SESSION_ALPHABET = '0123456789abcdefghjkmnpqrstvwxyz';
const PROJECT_RE = /^[a-z0-9](?:[a-z0-9-]{0,28}[a-z0-9])?$/;
const LOCAL_RE = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;

function isSessionId(value: string): boolean {
  return value.length === 6 && [...value].every((c) => SESSION_ALPHABET.includes(c));
}

function isProjectSlug(value: string): boolean {
  return PROJECT_RE.test(value) && value.length >= 2;
}

function parseAddress(raw: string, rootDomain: string, layout: 'flat' | 'subdomain'): { project: string; session: string; address: string } | null {
  // ASCII only: U+212A KELVIN SIGN would otherwise lowercase onto a real 'k'.
  for (let i = 0; i < raw.length; i++) if (raw.charCodeAt(i) > 127) return null;
  const address = raw.trim().toLowerCase();
  const at = address.lastIndexOf('@');
  if (at <= 0) return null;
  const local = address.slice(0, at);
  const domain = address.slice(at + 1);
  if (layout === 'subdomain') {
    if (!domain.endsWith(`.${rootDomain}`)) return null;
    const project = domain.slice(0, -(rootDomain.length + 1));
    const session = local.slice(0, 6);
    if (project.includes('.') || !isProjectSlug(project) || !isSessionId(session) || local[6] !== '-' || !LOCAL_RE.test(local.slice(7))) return null;
    return { project, session, address };
  }
  if (domain !== rootDomain) return null;
  const parts = local.split('.');
  if (parts.length !== 3) return null;
  const [project = '', session = '', label = ''] = parts;
  if (!isProjectSlug(project) || !isSessionId(session) || !LOCAL_RE.test(label)) return null;
  return { project, session, address };
}

function reply(status: number, body: unknown): Response {
  return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', 'cache-control': 'no-store' } });
}

Supabase Edge Function

The flowproof-hook function ships with the Supabase cleanup function and shares its signature check. Edit its config.ts (your project) and actions.ts; it finds the auth.users row with that exact address.

actions.ts
// supabase/functions/flowproof-hook/actions.ts
import type { HookActions } from './hook.ts';

export const ACTIONS: HookActions = {
  // What DrawRight's nightly cron runs for each owner on day 30, for this one owner, now.
  async send_30_day_checkin({ user, query }) {
    await query('select public.send_checkin_email($1::uuid)', [user.id]);
    return { sent: '30-day check-in' };
  },
};
Terminal
supabase secrets set FLOWPROOF_HOOK_SECRET=<secret> FLOWPROOF_VERIFY_TOKEN=fpv_...
supabase functions deploy flowproof-hook --no-verify-jwt
# endpoint: https://<ref>.supabase.co/functions/v1/flowproof-hook

WordPress

Version 0.4.0 of the Flowproof plugin adds the endpoint /wp-json/flowproof/v1/hook. Store the hook secret in Settings → Flowproof → Test hooks (next to the test checkout token), then handle each action in your own code. The plugin runs flowproof_test_hook_<action> with the WordPress user:

functions.php
// In your theme's functions.php or your own plugin.
add_action( 'flowproof_test_hook_send_renewal_reminder', function ( WP_User $user, array $params ) {
    my_membership_send_renewal_reminder( $user->ID ); // this user only
}, 10, 2 );

define( 'FLOWPROOF_TEST_HOOKS_DISABLED', true ); in wp-config.php turns it off.

Limits

  • One test hook per project; it can declare up to 50 actions.
  • 30 calls per run, and 6 a minute.
  • Params are a flat list of short values, up to 20 of them.
  • A call must be confirmed within five minutes, and only once.