Core concepts

Architecture

One canonical model, one execution pipeline, and protocol adapters that only translate.

The problem

A merchant already has an HTTP API. AI agents are learning to discover, invoke and pay for capabilities through a growing set of protocols. Implementing each one inside every merchant backend does not scale, and handing the money to a proprietary middleman defeats the point.

The shape of the answer

Three properties are load-bearing:

Self-hostedThe gateway runs in the merchant's infrastructure. There is no central service, and none is planned.
Non-custodialPayment goes from buyer to merchant without passing through the gateway, which needs no buyer or merchant key. Local facilitator mode holds a separate gas-paying signer.
ConfigurationYou expose an existing endpoint by describing it in config.yaml, not by rewriting it.

OpenAPI import is an ingress tool, not a second runtime. It terminates at the config boundary: an imported resource is indistinguishable at runtime from one typed by hand.

text
OpenAPI → importer → resource definitions → canonical model → pipeline

Canonical model

The core defines protocol-neutral commerce operations. It uses stable method names such as x402 and mpp for selection, but imports no MCP, JSON-RPC, x402 SDK, EIP-712 or EVM wire types. Protocol- and rail-specific representations exist only at adapter boundaries; a provider's native challenge passes through the core as an opaque value.

ConceptType
a thing an agent can invokeCommerceResource
what it costsPricing
how to reach the merchant backendBackendHandler
one inbound callCanonicalRequest
what must be paid, and the opaque provider challengePaymentRequirement
what happened to a paymentPaymentResult
proof of deliveryCommerceReceipt
everything worth observingCommerceEvent

Adding a protocol or a payment rail is one new adapter rather than a core rewrite, and semantics from one protocol cannot leak into another.

The execution pipeline

Every adapter converges here. Nothing bypasses it.

text
CanonicalRequest
  ├─ resolve resource ─────────────────► RESOURCE_NOT_FOUND
  ├─ validate input ───────────────────► INPUT_INVALID
  ├─ resolve price
  │
  ├─ free ───────────────────────────────────────────────┐
  └─ paid                                                │
       ├─ createRequirement                              │
       ├─ no proof ────► PaymentRequiredOutcome (402)    │ fail closed
       ├─ verify ──────► rejected ► PAYMENT_INVALID      │
       ├─ replayKey missing ────► PAYMENT_INVALID        │
       ├─ authorize + reserve ──► AUTHORIZATION_*        │
       ├─ reserve replayKey ────► PAYMENT_REPLAYED       │
       │                     or STORAGE_ERROR            │
       └─ upfront flow: settle ─► PAYMENT_SETTLEMENT_FAILED
                                                         │
  ┌──────────────────────────────────────────────────────┘
  ├─ call merchant backend ────────────► BACKEND_TIMEOUT / BACKEND_ERROR
  ├─ authorization flow: settle ───────► PAYMENT_SETTLEMENT_FAILED
  ├─ store receipt + events ───────────► logged on failure, never thrown
  └─ ExecutionOutcome (delivered)

verify never moves money; only settle does. The replay reservation sits deliberately between them, so a duplicate authorization is rejected before any funds move. The payment flow decides where settle runs. authorization, the x402 default, settles after a successful backend call and withholds the response if settlement fails; upfront, the MPP default, settles before the call. When a challenge lists several x402 offers, the proof selects one before verify, and every later step uses that offer. The authorization step is opt-in per resource and also sits between verify and settle, for the same reason. Its reservation is released only when a failure proves that no money moved.

Correlation

Every flow has one requestId, generated by the protocol adapter and carried through every log line, event, payment attempt and receipt.

text
resource.requested → payment.verified → backend.called → payment.settled
                   → resource.delivered

The challenge before it is a separate request with its own requestId: resource.requested → payment.required. Under the upfront flow, payment.settled comes before backend.called. A resource requiring authorization adds authorization.verified (or authorization.rejected) between the request and the payment events. The event types are the same whatever the payment or authorization method.

Adapter isolation

An optional adapter that fails to start is marked unhealthy and reported by doctor; it does not stop the process or affect the others. A protocol failure never becomes a payment failure, and vice versa.

Receipts and audit

SQLite, three core tables (receipts, events, payment_attempts) behind a thin repository. payment_attempts.replay_key carries a UNIQUE constraint, which is what makes the replay defense atomic rather than advisory. The pipeline omits raw payment proofs, and row mapping redacts recognized secret fields before anything is written.

payment_attempt_history (schema v3) preserves rejected x402 attempts replaced by retries, including the earlier request and rejection reason.

Schema v4 (2.5.0) adds the transactions journal, resolution metadata and a transaction_id column on receipts, events, payment attempts and attempt history. The migration leaves older rows unchanged, without a transactionId. See Transactions.

Schema v5 (2.6.0) moves AP2 authorization_holds and ACP idempotency_keys into the receipt database. It adds pending_tasks for unpaid A2A x402 tasks to survive restarts, and legacy_imports to track imported files; existing tables stay unchanged. A 2.5 gateway cannot open schema v5. See Storage for upgrades, backups and restores.

Deterministic by construction

LLMs are optional clients of this system, never dependencies of it. Routing, validation, payment verification, receipts and protocol adaptation are deterministic and testable without a model, a public RPC or real money.

Where to look in the code

ConcernPath
canonical model, errors, pipelinesrc/core
config schema, loader, env substitutionsrc/config
Fastify server, routes, adapter mountingsrc/gateway
MCP adaptersrc/protocols/mcp
A2A adapter (experimental)src/protocols/a2a
ACP checkout adapter (experimental)src/protocols/acp
shared bearer check, body reader, URL joinsrc/protocols/http.ts
x402 provider (EVM, Solana, NEAR) + local/remote facilitatorsrc/payments/x402
MPP provider, settling through an x402 providersrc/payments/mpp
AP2 mandate verificationsrc/authorization/ap2
SQLite receipts, events, attempts, journal, AP2 holds, ACP claims, A2A pending taskssrc/storage/receipts
OpenAPI import (config ingress only)src/openapi
CLI (init, import openapi, validate, start, doctor, reconcile, resolve, backup, demo, version)src/cli
demo merchant API, buyer agent, dashboarddemo/*
MockUSDC and local chain scriptscontracts/, scripts/chain/