Docs menuTesting paid signups

Testing paid signups

If your app takes payment at signup, your agent can’t test the emails that come after it without paying. Test checkout lets your server skip payment for Flowproof’s test users, and only for them.

Guide

The problem

The emails that matter most in a paid signup (“your subscription is active”, the order confirmation, the receipt) only exist after a checkout. Your agent must never type a real card, and on a live site a test card doesn’t work. So without help, those emails go untested.

How it works

Just before your server charges someone, it asks Flowproof one question:

Is acme.ah5wdq.buyer@flowproof.dev a test user of my project, in a test run that is open right now?

If Flowproof says yes, your server skips payment (or applies 100% off) for that user. When the run ends, cleanup deletes the user, their orders and their Stripe customer as usual.

The rules for your server

  • Ask from your server, never the browser. The token lives in your server’s secret settings, and the endpoint refuses browser requests, so front-end code can’t use it anyway.
  • Ask about the email your server already trusts: the signed-in account’s email, or the billing email on the order you’re creating. Never a value sent along with the “make this free” request.
  • Fail closed. Only an HTTP 200 with "testUser": true means yes. A timeout, an error, anything unexpected: charge as normal. Use a short timeout (3 seconds).
  • Decide when you create the order, in the same request, so nobody can verify one email and then switch to another.
  • Optional: only ask for addresses ending in flowproof.dev. Your real customers’ emails then never leave your system.

Set it up

  1. Create a verification token

    A verification token (it starts with fpv_) can do exactly one thing: ask whether an address is a live test user of one project. It can’t start runs, read email or call anything else. Create one per environment:

    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/verify-tokens \
      -H "Authorization: Bearer $FLOWPROOF_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{"label": "production"}'

    The token is the secret in the reply, shown once. Save it in your server’s secret settings as FLOWPROOF_VERIFY_TOKEN. Never put your fp_ API key in an app: the endpoint refuses it.

  2. Call Flowproof before you charge

    Add the check to the server code that creates the order or subscription. Snippets for common stacks are below.

  3. Tell your agent

    Nothing to configure: Flowproof’s rules already tell your agent to check out normally with its test address as the account and billing email, and to stop and tell you if a card form still appears.

The endpoint

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

{ "email": "acme.ah5wdq.buyer@flowproof.dev" }
ResponseMeaningYour app
200 {"testUser": true, "session": "ah5wdq", "expiresAt": "..."}A live test user of your projectSkip payment
200 {"testUser": false}Anything elseCharge as normal
400 / 413The body isn’t { "email": "..." }Charge as normal
401Missing, unknown or revoked token, or an fp_ API keyCharge as normal; fix the token
429Rate limited (120 checks a minute per token)Charge as normal
503Flowproof couldn’t answerCharge as normal

Snippets

The shared helper (TypeScript)

Everything below uses it. It returns true only on an explicit yes.

lib/flowproof.ts
// lib/flowproof.ts -- SERVER ONLY
const VERIFY_URL = 'https://mcp.flowproof.dev/api/test-users/verify';
const FLOWPROOF_ADDRESS = /@(?:[a-z0-9-]+\.)?flowproof\.dev$/i;

/** True only on an explicit yes from Flowproof. Every error is false: charge as normal. */
export async function isFlowproofTestUser(email: string | null | undefined): Promise<boolean> {
  const token = process.env.FLOWPROOF_VERIFY_TOKEN;
  if (!token || !email || !FLOWPROOF_ADDRESS.test(email.trim())) return false;
  try {
    const res = await fetch(VERIFY_URL, {
      method: 'POST',
      headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({ email: email.trim() }),
      signal: AbortSignal.timeout(3000),
      cache: 'no-store',
    });
    if (res.status !== 200) return false;
    const body: unknown = await res.json();
    return typeof body === 'object' && body !== null && (body as { testUser?: unknown }).testUser === true;
  } catch {
    return false;
  }
}

Next.js (App Router)

app/api/checkout/route.ts
// app/api/checkout/route.ts
import 'server-only';
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';            // your auth
import { stripe } from '@/lib/stripe';
import { grantPlan } from '@/lib/billing';     // your DB write
import { isFlowproofTestUser } from '@/lib/flowproof';

export async function POST(req: Request) {
  const user = await auth();                  // the email your server trusts, not one from the body
  if (!user) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
  const { priceId } = await req.json();

  if (await isFlowproofTestUser(user.email)) {
    await grantPlan(user.id, priceId, { source: 'flowproof_test' });
    return NextResponse.json({ url: '/welcome?plan=activated' });
  }

  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    customer_email: user.email,
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.APP_URL}/welcome?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.APP_URL}/pricing`,
  });
  return NextResponse.json({ url: session.url });
}

Supabase Edge Function

supabase/functions/start-checkout/index.ts
// supabase/functions/start-checkout/index.ts
// supabase secrets set FLOWPROOF_VERIFY_TOKEN=fpv_...
import { createClient } from 'jsr:@supabase/supabase-js@2';

const VERIFY_URL = 'https://mcp.flowproof.dev/api/test-users/verify';

async function isFlowproofTestUser(email: string | undefined): Promise<boolean> {
  const token = Deno.env.get('FLOWPROOF_VERIFY_TOKEN');
  if (!token || !email || !/@(?:[a-z0-9-]+\.)?flowproof\.dev$/i.test(email)) return false;
  try {
    const res = await fetch(VERIFY_URL, {
      method: 'POST',
      headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({ email }),
      signal: AbortSignal.timeout(3000),
    });
    return res.status === 200 && (await res.json())?.testUser === true;
  } catch {
    return false;
  }
}

Deno.serve(async (req) => {
  const supabase = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_ANON_KEY')!, {
    global: { headers: { Authorization: req.headers.get('Authorization') ?? '' } },
  });
  // The signed-in user's email, from their JWT -- not from the request body.
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) return new Response('unauthorized', { status: 401 });

  if (await isFlowproofTestUser(user.email)) {
    const admin = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!);
    await admin.from('subscriptions').upsert({ user_id: user.id, status: 'active', source: 'flowproof_test' });
    return Response.json({ free: true });
  }
  // ...create the Stripe Checkout Session as usual and return its URL.
  return Response.json({ free: false });
});

Node / Express

server.js
// app, requireAuth, grantPlan and stripe are yours.
import { isFlowproofTestUser } from './flowproof.js'; // the helper above, in JS

app.post('/api/checkout', requireAuth, async (req, res) => {
  const email = req.user.email; // from your session, never req.body
  if (await isFlowproofTestUser(email)) {
    await grantPlan(req.user.id, req.body.priceId, { source: 'flowproof_test' });
    return res.json({ url: '/welcome?plan=activated' });
  }
  const session = await stripe.checkout.sessions.create({ /* as usual */ });
  res.json({ url: session.url });
});

Keep Stripe in the loop: a 100%-off coupon

Skipping Stripe is simplest, but then Stripe’s receipts and your invoice.paid webhooks never run, and those are often the emails worth testing. Instead, apply a 100%-off coupon on the server, for verified test users only:

TypeScript
// Once, in the Stripe dashboard or a script: a coupon with NO promotion code, so nobody can type it.
// const coupon = await stripe.coupons.create({ percent_off: 100, duration: 'forever', name: 'Flowproof test user' });

const discounts = (await isFlowproofTestUser(user.email))
  ? [{ coupon: process.env.STRIPE_FLOWPROOF_COUPON_ID! }]
  : undefined;

// Stripe Checkout: with a $0 total and payment_method_collection 'if_required', no card is asked for.
const session = await stripe.checkout.sessions.create({
  mode: 'subscription',
  customer_email: user.email,
  line_items: [{ price: priceId, quantity: 1 }],
  ...(discounts ? { discounts, payment_method_collection: 'if_required' as const } : {}),
  metadata: discounts ? { flowproof_test: 'true' } : {},
  success_url: `${process.env.APP_URL}/welcome?session_id={CHECKOUT_SESSION_ID}`,
  cancel_url: `${process.env.APP_URL}/pricing`,
});

Never turn the coupon into a promotion code people can type, and decide the discount in the same request that creates the checkout. With Stripe cleanup armed, the test customer and its subscription are removed when the run ends.

WooCommerce: no code

The Flowproof plugin has test checkout built in:

  1. In Settings → Flowproof, make sure the project slug and root domain are set.
  2. Under Test checkout, paste the fpv_ token and click Save token. The plugin checks it with Flowproof and shows the status (“On” when it works).

At checkout, a verified billing email gets a $0 cart with no payment box. The order is checked again when it’s created, zeroed, and noted as a Flowproof test checkout. Anyone else pays as normal, including when Flowproof can’t be reached. To switch it off from wp-config.php:

wp-config.php
define( 'FLOWPROOF_TEST_CHECKOUT_DISABLED', true );

What’s left to know

A test address works as a free pass only while its run is open (24 hours by default, 7 days at most). If your app gives paid access before the user confirms their email, someone who learned a live test address in that window could use it until cleanup deletes the account. If that worries you, confirm email first (your agent can receive the email, that’s what Flowproof is for) or keep runs short. Every “yes” is recorded on the run’s timeline, so each free checkout can be traced to a run.

Manage tokens:

List tokens
curl -s https://mcp.flowproof.dev/api/projects/acme/verify-tokens \
  -H "Authorization: Bearer $FLOWPROOF_API_KEY"
Revoke a token (immediate, permanent)
curl -s -X DELETE https://mcp.flowproof.dev/api/projects/acme/verify-tokens/TOKEN_ID \
  -H "Authorization: Bearer $FLOWPROOF_API_KEY"

A project can have 10 active tokens at once. Test checkout is part of the Agency plan.