Meridian docs

Read-only x402 pay-per-call API · 0.8.0

Meridian returns DeFi risk cards from public chain data, an OFAC SDN exact-match screen, a parameter-drift check, and a transaction simulation dry-run. Clients pay USDC on Base with x402.

The sanctions screen exact-matches public U.S. Treasury OFAC SDN digital-currency addresses (ITA Consolidated Screening List as official fallback). A daily Worker cron (13:15 UTC) loads a small JSON extract (SDN_FEED_URL) and its sibling last-run.json heartbeat, not the full Treasury XML. The heartbeat must be younger than ~60 hours. GET /health reports sanctions.feed_source (ofac_sdn_xml / ita_csl_json), feed_source_kind, csl_fallback, csl_cross_check_mismatches, feed_fetched_at, feed_checked_at (the last-run.checked_at the Worker accepted), and stale when the last successful check is more than 36 hours old.

What it does NOT do

MioDio LLC, corp@miodiollc.com, 755 Monroe Rd, Unit 470067, Sanford, FL 32747 (Florida LLC).

Verified on PayAPI Market — PayAPI verified Meridian on 2026-10-17 with a real paid canary call to /v1/sanctions.

FAQ

Do you hold keys or sign transactions?
No. Meridian is read only. Your agent keeps its own wallet (AgentKit, CDP wallets, Safe, whatever you use) and calls Meridian before it signs.
What does it cost?
Half a cent ($0.005) for a single check, including the drift check and the payment check. A transaction simulation is 1 cent ($0.01), the deep passport or passport plus sanctions is 2 cents ($0.02), and screening up to 50 addresses is 10 cents ($0.10). Receipt lookup, receipt keys and Meridian payment status are free. You pay in USDC on Base over x402.
Do I need an account?
No. There is no account and no API key. Your agent pays per call with x402 and gets JSON back.
Can I try it free?
Yes. /v1/demo/risk and /v1/demo/sanctions show real results with no wallet. /v1/demo/vault shows a vault card, /v1/demo/drift shows a sample drift catch, and /v1/demo/simulate shows a sample dry run.
Where does the data come from?
Public chain data (Aave v3, Lido and Curve contracts, ERC-4626 vaults) and the public US Treasury OFAC SDN list. The drift check uses only the intent and transaction you send. Simulation also reads the latest public chain state. No social scraping, no private data.
Is this a compliance tool?
No. The sanctions screen is an exact address match against OFAC's crypto address entries. It is a fast first check, not a compliance program, and not legal or compliance advice.
Does no match mean clean?
No. No match does not mean clean. The screen only matches addresses OFAC lists; it does not look at names, owners, indirect links, history or non US lists. Each result shows the list date and how old our copy is, and we refresh our copy from OFAC once a day.
Does it simulate transactions or catch changed parameters?
Both. POST /v1/drift ($0.005) compares your intent to the exact unsigned transaction or permit and answers match, drift or cannot_verify; a match only means the two agree, not that the intent was safe. POST /v1/simulate ($0.01) dry runs the unsigned transaction on Base or Ethereum and shows success or revert, gas, balance changes, approvals and warnings. A simulation is a point in time dry run, not a guarantee of the outcome.
Do you track transactions after they are signed?
No, on purpose. Meridian is the check before signing. Tracking after signing is a different job.
Which chains?
Payment is USDC on Base. The Aave, vault and passport checks read Base or Ethereum, and the Lido check reads Ethereum. Simulation runs on Base or Ethereum.
What if I paid and the call failed?
If you were charged and got a server error or no response, email corp@miodiollc.com within 30 days with the tx hash, route and time for a refund or credit. Duplicate payments are refunded on request. You can first check for free with GET /v1/payment/status?tx=<hash>, which covers payments to Meridian only. To check any USDC payment on Base, use POST /v1/payment-check ($0.005).
How do I verify a receipt?
A paid 2xx response includes a signed Meridian-Receipt header when receipt signing is configured. Check it against the public keys at /.well-known/meridian-receipt-keys.json, or look it up at /v1/receipt/<id> for 30 days; step by step instructions are on /docs and a ready made checking script is at /receipt-verify.ts. Treat the receipt id like a password. For full proof, also confirm the USDC payment on Base.

Paid routes

RoutePriceNetwork
GET /v1/risk/:address example GET /v1/risk/0x0000000000000000000000000000000000000001~$0.005 (5000 atomic)eip155:8453
GET /v1/lido/exit-risk example GET /v1/lido/exit-risk?amount=32~$0.005 (5000 atomic)eip155:8453
GET /v1/vault/:address example GET /v1/vault/0xc1256Ae5FF1cf2719D4937adb3bbCCab2E00A2Ca~$0.005 (5000 atomic)eip155:8453
GET /v1/passport/:address example GET /v1/passport/0x0000000000000000000000000000000000000001~$0.02 (20000 atomic)eip155:8453
GET /v1/sanctions/:address example GET /v1/sanctions/0x0000000000000000000000000000000000000001~$0.005 (5000 atomic)eip155:8453
GET /v1/passport-sanctions/:address example GET /v1/passport-sanctions/0x0000000000000000000000000000000000000001~$0.02 (20000 atomic)eip155:8453
POST /v1/sanctions/batch~$0.10 (100000 atomic)eip155:8453
POST /v1/drift~$0.005 (5000 atomic)eip155:8453
POST /v1/simulate~$0.01 (10000 atomic)eip155:8453
POST /v1/payment-check~$0.005 (5000 atomic)eip155:8453

Unpaid calls return HTTP 402 with a PAYMENT-REQUIRED header (x402 v2). The JSON body and the PAYMENT-REQUIRED header carry the same x402 v2 PaymentRequired object (x402Version, accepts, resource). Empty or invalid POST bodies without a payment header still 402 so directory crawlers can read the challenge. Retry with PAYMENT-SIGNATURE. With a payment header, invalid input is 400 and is not settled — the x402 middleware settles only when the handler status is below 400. A paid 2xx also returns PAYMENT-RESPONSE and, when receipt signing is configured, a signed Meridian-Receipt. See /.well-known/x402 for atomic amounts, Base USDC asset, and payTo. OpenAPI paid operations include x-payment-info from the same price config.

How paying works: agent request, 402 Payment Required, USDC on Base, answer plus signed Meridian-Receipt

Verify a Meridian-Receipt

A paid 2xx may include a base64 JSON Meridian-Receipt header (also stored at GET /v1/receipt/:id; the id is a bearer secret). Third-party verification:

  1. Canonical bytes are recursively sorted-key JSON with no whitespace, every field except sig. This is not RFC 8785 JCS.
  2. Verify Ed25519 over those bytes against /.well-known/meridian-receipt-keys.json by kid. The JWKS key must have kty: "OKP" and crv: "Ed25519". sig is canonical standard base64 or base64url (padding required; Worker receipts use standard base64).
  3. Honor each key's not_before. Reject status: "revoked" even without revoked_at. Reject status: "retired" unless retired_at is a valid ISO 8601 UTC instant ending in Z, and then only accept issued_at before that time. Reject present-but-invalid timestamp fields (offsets like +00:00 or a missing Z are invalid).
  4. Also confirm the on-chain USDC Transfer on Base whose transaction, sender, recipient, and value match tx, payer, pay_to, and amount. Receipt money fields come from settlement or the advertised route terms, never the client payment header.

Copy-paste verifier (also /receipt-verify.ts):

/**
 * Offline Meridian-Receipt verifier (Node 22+ / Workers WebCrypto).
 *
 * Canonical bytes are recursively sorted-key JSON with no whitespace,
 * excluding `sig`. This is NOT RFC 8785 JSON Canonicalization Scheme (JCS).
 *
 * After the signature verifies, also confirm the on-chain USDC Transfer on
 * Base (eip155:8453) whose tx hash, from, to, and value match receipt.tx,
 * receipt.payer, receipt.pay_to, and receipt.amount.
 *
 * JWKS keys must include kty "OKP" and crv "Ed25519". alg, when present,
 * must be EdDSA or Ed25519 (live keys use alg "Ed25519").
 *
 * `sig` is strict standard base64 or canonical base64url: padding required,
 * no whitespace, and re-encoding must match the input exactly. Worker-issued
 * receipts use standard base64 (`btoa`).
 *
 * Timestamps (receipt `issued_at`; key `not_before` / `retired_at` /
 * `revoked_at`) must be ISO 8601 UTC instants ending in Z.
 */

export function stableJson(value: unknown): string {
  if (value == null || typeof value !== "object") return JSON.stringify(value);
  if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
  const obj = value as Record<string, unknown>;
  const keys = Object.keys(obj).sort();
  return `{${keys.map((key) => `${JSON.stringify(key)}:${stableJson(obj[key])}`).join(",")}}`;
}

export function receiptCanonicalBytes(receipt: Record<string, unknown>): Uint8Array {
  const unsigned: Record<string, unknown> = {};
  for (const key of Object.keys(receipt)) {
    if (key === "sig") continue;
    unsigned[key] = receipt[key];
  }
  return new TextEncoder().encode(stableJson(unsigned));
}

/** ISO 8601 UTC instant ending in Z (optional 1–9 fractional-second digits). */
const ISO_INSTANT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?Z$/;

export function isIsoInstant(value: unknown): value is string {
  return typeof value === "string" && ISO_INSTANT_RE.test(value) && !Number.isNaN(Date.parse(value));
}

/** Canonical standard base64 or base64url. Re-encoding must match exactly. */
export function decodeStrictB64(text: string): Uint8Array | null {
  if (typeof text !== "string" || text.length === 0) return null;
  if (/\s/.test(text)) return null;
  const isUrl = /^[A-Za-z0-9_-]+={0,2}$/.test(text);
  const isStd = /^[A-Za-z0-9+/]+={0,2}$/.test(text);
  if (!isUrl && !isStd) return null;
  if (text.length % 4 !== 0) return null;
  const normalized = text.replace(/-/g, "+").replace(/_/g, "/");
  let binary: string;
  try {
    binary = atob(normalized);
  } catch {
    return null;
  }
  const reStd = btoa(binary);
  const reencoded = isUrl ? reStd.replace(/\+/g, "-").replace(/\//g, "_") : reStd;
  if (reencoded !== text) return null;
  const out = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
  return out;
}

export type ReceiptKey = {
  kid?: unknown;
  x?: unknown;
  kty?: unknown;
  crv?: unknown;
  alg?: unknown;
  status?: unknown;
  not_before?: unknown;
  retired_at?: unknown;
  revoked_at?: unknown;
};

export type KeyWindowResult = { ok: true } | { ok: false; reason: string };

function timestampField(
  value: unknown,
): { kind: "absent" } | { kind: "invalid" } | { kind: "ok"; ms: number } {
  if (value === undefined) return { kind: "absent" };
  if (!isIsoInstant(value)) return { kind: "invalid" };
  return { kind: "ok", ms: Date.parse(value) };
}

export function keyCoversIssuedAt(key: ReceiptKey, issuedAt: string): boolean {
  return keyWindowCheck(key, issuedAt).ok;
}

export function keyWindowCheck(key: ReceiptKey, issuedAt: string): KeyWindowResult {
  if (!isIsoInstant(issuedAt)) return { ok: false, reason: "invalid_issued_at" };
  const issued = Date.parse(issuedAt);
  const notBefore = timestampField(key.not_before);
  const retiredAt = timestampField(key.retired_at);
  const revokedAt = timestampField(key.revoked_at);
  if (notBefore.kind === "invalid" || retiredAt.kind === "invalid" || revokedAt.kind === "invalid") {
    return { ok: false, reason: "invalid_key_timestamp" };
  }
  if (key.status === "revoked" || revokedAt.kind === "ok") {
    return { ok: false, reason: "key_revoked" };
  }
  if (key.status === "retired" && retiredAt.kind !== "ok") {
    return { ok: false, reason: "retired_without_retired_at" };
  }
  if (key.status !== undefined && key.status !== "active" && key.status !== "retired") {
    return { ok: false, reason: "invalid_key_status" };
  }
  if (notBefore.kind === "ok" && issued < notBefore.ms) {
    return { ok: false, reason: "key_outside_validity_window" };
  }
  if (retiredAt.kind === "ok" && issued >= retiredAt.ms) {
    return { ok: false, reason: "key_outside_validity_window" };
  }
  return { ok: true };
}

export type VerifyReceiptResult =
  | { ok: true }
  | { ok: false; reason: string };

const KEY_ALG_OK = new Set(["Ed25519", "EdDSA"]);

export async function verifyMeridianReceipt(
  receipt: Record<string, unknown>,
  keysDoc: { keys?: unknown },
): Promise<VerifyReceiptResult> {
  if (typeof receipt.sig !== "string" || !receipt.sig) {
    return { ok: false, reason: "missing_sig" };
  }
  if (receipt.alg !== "Ed25519") return { ok: false, reason: "unsupported_alg" };
  if (typeof receipt.kid !== "string" || !receipt.kid) {
    return { ok: false, reason: "missing_kid" };
  }
  if (typeof receipt.issued_at !== "string") {
    return { ok: false, reason: "missing_issued_at" };
  }
  if (!Array.isArray(keysDoc.keys)) {
    return { ok: false, reason: "invalid_keys" };
  }
  const rawKeys = (keysDoc.keys ?? []) as unknown[];
  if (rawKeys.some((item) => item == null || typeof item !== "object")) {
    return { ok: false, reason: "invalid_keys" };
  }
  const keys = rawKeys as ReceiptKey[];
  const matches = keys.filter((item) => item.kid === receipt.kid);
  if (matches.length > 1) return { ok: false, reason: "duplicate_kid" };
  const key = matches[0];
  if (!key || typeof key.x !== "string") return { ok: false, reason: "unknown_kid" };
  if (key.kty !== "OKP") return { ok: false, reason: "bad_kty" };
  if (key.crv !== "Ed25519") return { ok: false, reason: "bad_crv" };
  if (key.alg !== undefined && !KEY_ALG_OK.has(String(key.alg))) {
    return { ok: false, reason: "bad_alg" };
  }
  const window = keyWindowCheck(key, receipt.issued_at);
  if (!window.ok) return window;
  const sig = decodeStrictB64(receipt.sig);
  if (!sig) return { ok: false, reason: "bad_sig_encoding" };
  try {
    const verifyKey = await crypto.subtle.importKey(
      "jwk",
      { kty: "OKP", crv: "Ed25519", x: key.x, ext: true },
      { name: "Ed25519" },
      false,
      ["verify"],
    );
    const ok = await crypto.subtle.verify(
      { name: "Ed25519" },
      verifyKey,
      sig,
      receiptCanonicalBytes(receipt),
    );
    return ok ? { ok: true } : { ok: false, reason: "bad_signature" };
  } catch {
    return { ok: false, reason: "verify_error" };
  }
}

Free demos

On Workers Free (~4 KV writes per paid call, 1,000 writes/day) about 250 paid calls/day. Past the cap, receipt lookup may 404 and payment status may say not_found.