EN

TypeScript SDK · v0.2.0

Verify the requirement before the payment wrapper sees it.

Compare the x402 v2 requirement your app received with IFF's independent observation. Keep consistent, diverged, stale, and unobserved as separate inputs to your own payment policy.

  • Node 20+
  • Modern browsers
  • ES modules
  • TypeScript types
Install from npm
npm install @ifandonlyif/x402-preflight

Browser/Node library, TypeScript declarations and a one-tool MCP command.

  1. 01Receive 402Your payment client receives a requirement.
  2. 02DecodeThe SDK reads the x402 v2 envelope.
  3. 03CompareIFF checks it against signed observations.
  4. 04Apply policyYour code decides whether to continue.

01 · Install

One package, no API key.

Preflight verification uses IFF's public production API. No owner authentication.

Terminal
npm install @ifandonlyif/x402-preflight
Runtime contract

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.

What to compare when payTo changes

Save as preflight.mjs · run with node preflight.mjs
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.
A bounded, explicit policy

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.

Reusable observation helper · does not invoke a payment client
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.

TypeScript
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.
Important: this wrapper is fail-open on IFF errors.

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.

Run without a global install
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.

Read the Apostille MCP security model.

06 · Verdicts and failure handling

Four states, no hidden score.

The SDK returns the API vocabulary without a trust label.

consistent

The received requirement matches the comparison window.

diverged

The amount or payee fingerprint differs from observation.

stale

Evidence exists but is outside its freshness window.

unobserved

IFF has no comparable fingerprint observation.

Application example versus v0.2.0 wrapper defaults
ResultDirect exampleDefault wrapFetch
consistentContinue to your own policy only.Return the original 402.
divergedHold; review the observed change.Throw X402DivergenceError.
stale + matchHold for fresh evidence.Warn and return the original 402.
stale + mismatchHold; stale does not erase the mismatch.Throw X402StaleMismatchError.
unobservedHold; no comparable evidence is not a risk verdict.Warn and return the original 402.
IFF timeout / network failureHold; report verification unavailable.Return the original 402 after failure; no built-in timeout.
IFF 429Retry 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]