Your first email check

From an empty folder to one paid screening call in six steps. One step spends real funds: at most $0.003 per run.

Screening only: syntax, DNS mail exchanger, disposable-domain list, role-account and typo heuristics. It does not verify that the mailbox exists or that mail will be delivered.

1. Install

Node 22.9 or newer. These are the versions our own service is built and tested with.

pnpm add @x402/fetch@2.28.0 @x402/evm@2.28.0 viem@2.57.7
pnpm add -D tsx@4.23.15

2. A wallet for the script

Generate a key for this purpose on your own machine and keep it in a .env file that your repository ignores. Never commit it, and never put a private key in a browser page: the script runs server-side.

node -e "import('viem/accounts').then(m => console.log('BUYER_EVM_PK=' + m.generatePrivateKey()))" >> .env
echo .env >> .gitignore
node --env-file=.env -e "import('viem/accounts').then(m => console.log(m.privateKeyToAccount(process.env.BUYER_EVM_PK).address))"

The second command prints the wallet's address, which is what you fund.

3. Fund it

Send USDC on Base (network eip155:8453) to that address. $1 covers 333 checks at $0.003; $3 covers a thousand. The wallet needs no ETH: the facilitator pays the gas. Fund only what you are willing to spend from this key.

Testing without real funds. The public API settles on mainnet only. To test for free, run a copy of the service locally (pnpm --filter x402-endpoints dev in our repository): it offers Base Sepolia terms through the x402.org testnet facilitator. Fund the same key with test USDC from Circle's faucet and point the script at it with PAYWALLWATCH_URL=http://localhost:8787.

4. Run the check

This step spends real funds. The script makes one payment attempt and pays exactly the amount in the 402 terms: at most $0.003 per run. Before signing anything it reads the terms with an unpaid request and refuses if the price is above its MAX_CHARGE_USD. It never retries a payment on its own.

node --env-file-if-exists=.env --import tsx first-check.ts jane@iana.org

Expected output: the terms, then the settlement (transaction hash, network, payer) and the verdict.

5. Read the response

FieldMeaning
verdictdomain_accepts_mail, risky, disposable, undeliverable or unknown (only with a 503).
reasonsWhy: invalid_syntax, domain_not_found, null_mx, no_mail_exchanger, no_mx_record_only_a_record, dns_lookup_failed, disposable_domain, typo_suspected.
suggestionA corrected address when a common typo is suspected; ask the person to confirm it.
dns_status, has_mxWhat DNS said about the domain. A mail exchanger means the domain accepts mail, not that the mailbox exists.
role_account, free_providerFlags for your own rules. They never change the verdict.
mailbox_verifiedAlways false. No SMTP conversation takes place.

Worked examples for every verdict are on the sample page.

6. Errors, and when you are charged

StatusWhat it meansCharge
402, no payment sentThe terms. Expected on the first request.none
402 after a paymentThe payment was rejected (signature, balance, wrong network); the body says why.none
400The payment carried no usable email. Refused before the facilitator is asked.none
503 with Retry-AfterThe DNS lookup failed; nothing is known about the domain. Retry later yourself.none: settlement is cancelled
200The screening result, with the settlement in PAYMENT-RESPONSE.$0.003
5xxA fault in the service.none: settlement is cancelled

The result is computed first and returned only once settlement succeeds. If settlement fails after the check ran, you get a 402 with the reason instead of the result, and you are not charged.

To prove the payment path before a real check, GET https://api.paywallwatch.com/v1/ping does the same dance for $0.01 and returns the server time.

The script

Copy this into first-check.ts. It is examples/quickstart/first-check.ts in our repository, and a test keeps this page equal to that file. A second example there, route-lead.ts, turns the verdict into a routing decision for a lead.

// PaywallWatch quickstart: one paid email check, one payment attempt, a charge cap.
//
//   node --env-file-if-exists=.env --import tsx first-check.ts jane@iana.org
//
// Needs BUYER_EVM_PK in .env: a private key generated for this purpose, holding a little USDC on Base. It stays in .env
// on your machine: never in a browser, never in a repository. The facilitator pays the gas, so the wallet needs no ETH.
// Optional: PAYWALLWATCH_URL to point at a local copy of the service that settles on Base Sepolia.
import { wrapFetchWithPayment, x402Client, decodePaymentResponseHeader } from "@x402/fetch";
import { ExactEvmScheme } from "@x402/evm/exact/client";
import { privateKeyToAccount } from "viem/accounts";

const MAX_CHARGE_USD = 0.01; // the script refuses to pay more than this for one check; the route costs $0.003
const origin = process.env.PAYWALLWATCH_URL ?? "https://api.paywallwatch.com";
const email = process.argv[2];
if (!email) { console.error("usage: first-check.ts <email>"); process.exit(2); }
const pk = process.env.BUYER_EVM_PK;
if (!pk || !pk.startsWith("0x")) { console.error("BUYER_EVM_PK is not set. Put it in .env, never in code and never in a browser."); process.exit(2); }

const url = `${origin}/v1/email/verify?email=${encodeURIComponent(email)}`;

// 1. Read the terms without paying. An unpaid request answers 402 with the price; nothing is charged.
const unpaid = await fetch(url);
if (unpaid.status !== 402) { console.error(`expected 402 with the terms, got ${unpaid.status}: ${(await unpaid.text()).slice(0, 200)}`); process.exit(1); }
const terms = JSON.parse(atob(unpaid.headers.get("PAYMENT-REQUIRED") ?? "")) as { accepts: { network: string; amount: string; extra?: { name?: string } }[] };
const evm = terms.accepts.find(a => a.network.startsWith("eip155:"));
if (!evm) { console.error("no EVM payment option in the terms"); process.exit(1); }
const usd = Number(evm.amount) / 1_000_000; // USDC has six decimals
console.log(`terms: ${usd.toFixed(6)} ${evm.extra?.name ?? "USDC"} on ${evm.network}`);
if (usd > MAX_CHARGE_USD) { console.error(`refusing to pay ${usd}: above MAX_CHARGE_USD ${MAX_CHARGE_USD}`); process.exit(1); }

// 2. Pay once. wrapFetchWithPayment signs one USDC authorization for exactly the amount in the terms and sends the
//    request once more with it. It does not loop: a rejected payment comes back as a 402 with the reason, uncharged.
const client = new x402Client();
client.register("eip155:*", new ExactEvmScheme(privateKeyToAccount(pk as `0x${string}`)));
const paidFetch = wrapFetchWithPayment(fetch, client);
const res = await paidFetch(url);
const text = await res.text();
let body: Record<string, unknown> = {};
try { body = JSON.parse(text) as Record<string, unknown>; } catch { /* a non-JSON body is reported below as text */ }
const settlement = res.headers.get("PAYMENT-RESPONSE");

// 3. Read the answer. Only a 200 is charged, and it carries the settlement in PAYMENT-RESPONSE.
if (res.status === 200) {
  console.log(`charged ${usd.toFixed(6)} USDC:`, settlement ? decodePaymentResponseHeader(settlement) : "(no PAYMENT-RESPONSE header)");
  console.log(`verdict ${String(body.verdict)} for ${String(body.email)}; reasons ${JSON.stringify(body.reasons)}; suggestion ${String(body.suggestion ?? "none")}`);
  console.log("mailbox_verified:", body.mailbox_verified, "(screening never verifies that the mailbox exists)");
} else if (res.status === 503) {
  console.log(`503: the DNS lookup failed; not charged. Retry-After ${res.headers.get("Retry-After")} s. Run again later: this script does not retry on its own.`);
} else if (res.status === 402) {
  console.log(`402 after payment: not charged. ${String(body.error ?? text.slice(0, 200))}`);
} else if (res.status === 400) {
  console.log(`400: not charged. ${String(body.error)}`);
} else {
  console.log(`${res.status}: not charged. ${text.slice(0, 200)}`);
}
process.exit(res.status === 200 ? 0 : 1);