01 · Install
One package, no API key.
Preflight verification uses IFF's public production API. No owner authentication.
npm install @ifandonlyif/x402-preflight
Uses the global fetch and WebCrypto. It holds no wallet key, makes no payment and does not modify the 402 response.
02 · Direct verification
Start with a comparison, not a payment.
This Node 20+ example sends only the requirement to IFF. The URL and payment fields are fixtures: use the exact endpoint and x402 v2 envelope your client received. It does not contact the provider or pay.
import { verify, VerifyRequestError } from "@ifandonlyif/x402-preflight";
// Demonstration data, not a request to buy this service.
const endpoint = "https://api.example.com/v1/quote";
const paymentRequired = {
x402Version: 2,
accepts: [{
scheme: "exact",
network: "eip155:8453",
asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
amount: "1000",
payTo: "0x1111111111111111111111111111111111111111",
maxTimeoutSeconds: 60,
}],
};
// Example application policy, NOT wrapFetch's default policy.
function decide(result) {
switch (result?.verdict) {
case "consistent":
return result.matches_last_observed === true
? { state: "consistent", action: "continue_to_own_policy" }
: { state: "unexpected_result", action: "hold" };
case "diverged":
return { state: "diverged", action: "hold" };
case "stale":
return {
state: result.matches_last_observed === true ? "stale_match"
: result.matches_last_observed === false ? "stale_mismatch"
: "stale_unknown",
action: "hold",
};
case "unobserved":
return { state: "unobserved", action: "hold" };
default:
return { state: "unexpected_result", action: "hold" };
}
}
async function preflightDecision(url, requirement) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000);
try {
const result = await verify(url, requirement, {
fetch: (input, init) => fetch(input, {
...init, signal: controller.signal, redirect: "error",
}),
});
return decide(result);
} catch (error) {
if (error instanceof VerifyRequestError) {
return error.status === 429
? { state: "iff_rate_limited", action: "retry_later" }
: { state: "iff_request_failed", action: "hold", status: error.status };
}
return {
state: controller.signal.aborted ? "iff_timeout" : "iff_unavailable",
action: "hold",
};
} finally {
clearTimeout(timer);
}
}
// Log the decision only, not the requirement or raw error body.
console.log(await preflightDecision(endpoint, paymentRequired));
// No payment follows, including when the state is consistent.
The 3-second deadline is application code: verify() has no built-in timeout or retry. This example holds every stale or unknown result. continue_to_own_policy means only that the comparison passed; your wallet, budget, merchant and payment rules still apply. An unknown fixture normally returns unobserved.
Use a full public HTTPS endpoint path without credentials, query or fragment, and the received envelope, not a guessed price or address. To decode a PAYMENT-REQUIRED header, use decodePaymentRequiredHeader(); browsers need the provider's CORS policy to expose that header.
03 · Evaluate without enforcement
Observe beside your existing policy.
Collect comparison outcomes without changing your payment decision. Shadow evaluation is an application-side call to verify(), not an SDK mode or a wrapFetch() default.
import { verify, VerifyRequestError } from "@ifandonlyif/x402-preflight";
export async function shadowPreflight(url, paymentRequired, record = console.info) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000);
let event;
try {
const result = await verify(url, paymentRequired, {
fetch: (input, init) => fetch(input, {
...init, signal: controller.signal, redirect: "error",
}),
});
const verdicts = ["consistent", "diverged", "stale", "unobserved"];
event = {
mode: "shadow",
state: verdicts.includes(result?.verdict) ? result.verdict : "unexpected_result",
matches_last_observed: typeof result?.matches_last_observed === "boolean"
? result.matches_last_observed : null,
};
} catch (error) {
event = {
mode: "shadow",
state: error instanceof VerifyRequestError && error.status === 429
? "iff_rate_limited" : controller.signal.aborted ? "iff_timeout" : "iff_unavailable",
};
} finally {
clearTimeout(timer);
}
// Do not expose the URL, requirement, API error body or wallet details.
try { await record(event); } catch { /* Telemetry must not change policy. */ }
}
// At the point where your client already has the actual requirement:
// void shadowPreflight(exactEndpointUrl, actualPaymentRequired);
// Keep the existing payment decision separate; this helper never authorizes it.
This makes one IFF comparison request and no provider request. IFF receives the submitted requirement over HTTPS; the helper limits only what your logs record. When DISCOVERY_ENABLED is on, an unobserved URL may be kept as a discovery candidate; the requirement is not stored.
04 · Payment wrappers
Know what the wrapper allows through.
Wrap the unpaid base fetch, then give it to your payment library. Wrapping a payment-enabled fetch can compare too late.
import {
wrapFetch, X402DivergenceError, X402StaleMismatchError,
} from "@ifandonlyif/x402-preflight";
const preflightFetch = wrapFetch(fetch, {
onDiverged: "throw",
onStale: "strict",
onUnobserved: "warn",
});
export async function fetchWithPreflight(url, init) {
try {
return await preflightFetch(url, { ...init, redirect: "error" });
} catch (error) {
if (error instanceof X402DivergenceError) {
console.warn("IFF requirement diverged; payment flow stopped.");
} else if (error instanceof X402StaleMismatchError) {
console.warn("IFF stale evidence mismatched; payment flow stopped.");
}
// Do not turn a rejected preflight into a bare-fetch/payment retry.
throw error;
}
}
// Give fetchWithPreflight to your payment library as its base fetch.
// This snippet alone does not send a request or enable payments.
onDiverged- Defaults to
throw. A mismatch stops the wrapper before payment logic proceeds. onStale- Defaults to
strict. A stale mismatch throws; stale matching evidence warns. onUnobserved- Defaults to
warn. Missing evidence is visible, not labelled unsafe.
In v0.2.0, an IFF network error, timeout, 429 or other non-success response passes the original 402 through unchanged, without a wrapper warning. Non-402 and undecodable 402 responses also pass through. There is no built-in deadline or retry. Use the direct example if unavailability must hold the flow.
After an accepted comparison the original body stays readable; a divergence or stale-mismatch error rejects the fetch promise. A stale result missing matches_last_observed warns. The wrapper does not independently verify the cryptographic proofs in the API result.
05 · MCP
Connect an agent through MCP.
Three separate MCP surfaces.
Public HTTP alpha
13 anonymous, read-only tools for evidence, fingerprints, transparency logs and receipt checks, over Streamable HTTP. Stateless JSON; no authentication, OAuth or SSE session. Verification runs on the hosted service. No payments and no submit_discovery_candidate.
https://iff-mcp-api-production.up.railway.app/mcp/iff
Public npm command
The package includes the stdio command iff-x402-preflight-mcp with one tool, verify_x402_endpoint.
npx --package=@ifandonlyif/x402-preflight \
iff-x402-preflight-mcp
Local Go internal alpha preview
The full Go stdio servers are an internal alpha. Source and binaries are not distributed, not in the npm package, not the hosted HTTP service and not in the public Apostille repository.
iff-mcp: 14 tools for public evidence, requirement comparison, transparency-log data and local STH, inclusion, consistency and Service Receipt checks. No payments.apostille-mcp: 20 tools for offline verification and optional registration, local signing, submission and private ERC-8004 workflows. Authenticated actions need explicit configuration and operator-named key files. Trusting verification needs independent exact issuer/key pins.
06 · Verdicts and failure handling
Four states, no hidden score.
The SDK returns the API vocabulary without a trust label.
consistentThe received requirement matches the comparison window.
divergedThe amount or payee fingerprint differs from observation.
staleEvidence exists but is outside its freshness window.
unobservedIFF has no comparable fingerprint observation.
| Result | Direct example | Default wrapFetch |
|---|---|---|
consistent | Continue to your own policy only. | Return the original 402. |
diverged | Hold; review the observed change. | Throw X402DivergenceError. |
stale + match | Hold for fresh evidence. | Warn and return the original 402. |
stale + mismatch | Hold; stale does not erase the mismatch. | Throw X402StaleMismatchError. |
unobserved | Hold; no comparable evidence is not a risk verdict. | Warn and return the original 402. |
| IFF timeout / network failure | Hold; report verification unavailable. | Return the original 402 after failure; no built-in timeout. |
IFF 429 | Retry later under your rate-limit policy. | Return the original 402, without warning. |
429, network errors and IFF 5xx are not evidence of divergence. Do not busy-loop: respect Retry-After on the direct HTTP API, or use bounded backoff with jitter and a retry cap. VerifyRequestError exposes status and body, not response headers. Fix 400/422 requests before retrying. These snippets perform no automatic retries.
Before enforcing a pilot, replay all seven rows with fixtures, including both stale cases, and keep API outages separate from endpoint evidence. Review the versioned v0.2.0 implementation.
07 · Boundary
Evidence is an input, not a guarantee.
- The SDK never submits a paid canary transaction.
- A match does not guarantee delivery after payment, and is not a payment-safety claim. There is no composite score.
- Monitor signatures show provenance and tamper evidence, not attestation or trusted execution.
- Service Receipt v2 is an experimental direct-API showcase outside the core preflight flow (local verifier). It is not an x402 payment receipt and does not prove payment, service delivery, caller identity, remote execution, result correctness, or safety.
- Payment policy and risk remain your application's responsibility.
Questions or security reports: [email protected]