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.
Meridian-Receipt headers.Meridian-Receipt when receipt signing is configured (lookup GET /v1/receipt/:id). The receipt id is a bearer secret — do not share it. The receipt is not a payment.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.
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).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.| Route | Price | Network |
|---|---|---|
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.
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:
sig. This is not RFC 8785 JCS.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).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).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" };
}
}
GET /health, GET /v1/receipt, GET /v1/receipt/:id (id is a bearer secret)GET /.well-known/meridian-receipt-keys.json, GET /v1/payment/status (30/min per IP; no receipt_id; on a miss use paid POST /v1/payment-check)GET /v1/demo/risk, GET /v1/demo/vault, GET /v1/demo/sanctions, GET /v1/demo/drift, GET /v1/demo/simulateGET /.well-known/x402, GET /.well-known/x402list.txt (404 when the optional ownership token is unset), GET /.well-known/agent-card.json, GET /robots.txtGET /llms.txt, GET /openapi.json, GET /docs, GET /receipt-verify.tsGET /terms, GET /privacy, GET /disclaimerOn 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.