@celo/buy-gateway
v0.8.5
Published
buy gateway — serve an x402 paywall from a declarative YAML config
Readme
@celo/buy-gateway
Declarative x402 paywall gateway for Celo stablecoin payments.
Turns a YAML configuration file (paywall.yml) into an authenticated HTTP proxy server that intercepts requests, returns 402 Payment Required challenges, verifies EIP-3009 transfer authorizations (and optional Self.xyz KYC proofs), settles payments on Celo, and proxies requests upstream.
Features
- Multi-Service Routing: Route distinct path prefixes to different upstream backends (e.g. Alchemy, Dune, custom AI backends).
- Prefix Stripping (
stripPrefix): Map/alchemy/v1/queryto upstream/v1/query. - Granular Rate Limiting: Enforce token-bucket rate limits per client IP (
scope: ip), per paying wallet (scope: payer), or per upstream provider (scope: service). - HTTP Method & Body Caps: Restrict permitted HTTP verbs (
methods: [GET, POST]) and enforce maximum body payloads (maxBodyBytes). - JSON Body Checks: Refuse a body that is not JSON (
json: true), one whose top-level fields are outside an allowlist of values (jsonFields: { model: [some-model] }), or one that carries a field you did not list (jsonOnlyFields: [model, messages], which also refuses a query string), with a 400 before the 402, so a request the upstream would reject or that asks for something you do not sell is never charged. - Text-Only Messages: For a chat-style route,
textOnlyMessages: truerequires eachmessages[]entry to carry onlyrole,contentandname, withcontenta string or an array of{type: "text", text}parts. An image, audio or file part is refused with a 400 before the 402. Such a part is a few dozen body bytes, but the provider fetches it and bills thousands of prompt tokens, so without this checkmaxBodyBytesdoes not bound what a flat-priced call costs you. Impliesjson; the body is forwarded as it was checked.TEXT_ONLY_MESSAGES_SCHEMAis the matching JSON Schema for a published input schema. - Forwarded Headers: List the only caller headers a proxy service passes on (
forwardHeaders: [content-type, accept]), so a provider's own control headers cannot ride along. Injected credentials are sent either way. - JSON Batch Caps: Bound entries in a top-level JSON array (
maxJsonArrayLength: 2000) before payment, including JSON-RPC notifications. Implies valid JSON; single requests and accepted batches keep their original bytes. - Soft-KYC Gates: Cross-bind payment signatures to Self.xyz zero-knowledge passport proofs (
scope,minimumAge,ofac,excludedCountries).
Configuration Reference
name: my-gateway
version: 1
network: celo-sepolia
payTo: "0x0000000000000000000000000000000000C0FFEE"
# Behind a reverse proxy (Cloud Run, an ALB, Cloudflare), set this so IP-scoped
# limits key on the address the proxy appended to X-Forwarded-For — the LAST
# entry — rather than on the proxy's own address, which every caller would
# share. Leave it unset when the gateway is reached directly: with it set, a
# client-supplied X-Forwarded-For would be believed.
trustProxy: true
# Global rate limit across all incoming requests. Runs before any payment is
# read, so its only enforceable scope is `ip`.
rateLimit:
scope: ip
requestsPerMinute: 300
burst: 50
# Named Upstream Services
services:
alchemy:
mode: proxy
upstream: https://eth-sepolia.g.alchemy.com
timeoutMs: 10000
injectHeaders:
Authorization: ALCHEMY_API_KEY
rateLimit:
scope: service
requestsPerMinute: 120
burst: 20
dune:
mode: proxy
upstream: https://api.dune.com
# Covers the whole upstream answer, body included (default 30000). The
# body is also capped at 10 MiB decoded; past either limit the caller gets
# a 504 or 502 that still carries the payment receipt. A caller who
# disconnects aborts the upstream request.
timeoutMs: 30000
injectHeaders:
X-DUNE-API-KEY: DUNE_API_KEY
rateLimit:
scope: payer
requestsPerMinute: 30
mock-ai:
mode: respond
contentType: application/json
body: '{"status":"ok","output":"Synthetic model output"}'
# Endpoints & Allowlist
endpoints:
- path: /healthz
service: mock-ai
free: true
restrictions:
methods: [GET]
- path: /alchemy/*
service: alchemy
stripPrefix: /alchemy
price: 0.001 USDC
restrictions:
methods: [POST, GET]
maxBodyBytes: 32768
- path: /dune/*
service: dune
stripPrefix: /dune
price: 0.05 USDC
self:
scope: buy-dune
minimumAge: 18
ofac: trueWhere each rateLimit.scope is enforced
A scope is only accepted where the server consults it; anywhere else the config is refused at boot rather than silently ignored.
| level | ip | payer | service |
|---|---|---|---|
| top-level rateLimit | ✓ | — | — |
| services.<name>.rateLimit (and routing.rateLimit) | ✓ | ✓ (priced endpoints only) | ✓ |
| endpoint rateLimit (priced) | ✓ | ✓ | — |
| endpoint rateLimit (free: true) | ✓ | — | — |
ip limits count every request, including the unpaid 402 probe, so a priced
endpoint with burst: 1 can never be bought: the probe spends the token and the
paid retry is refused. Size burst for probe + retry.
A service-level payer limit is consulted after payment verification, so a
service with a free: true endpoint cannot carry one: it is refused at boot
rather than silently letting free calls through.
service limits count only requests that reach the upstream — a free one when
it is served, a paid one after verification and before settlement (a refusal
there hands the payer's nonce back, so the same signed payment can be retried).
The 402 probe does not count: it never reaches the upstream, and a bucket it
could drain would let anyone throttle every paying customer without paying.
Two things the protocol does not do
A payment is not bound to the endpoint it was quoted for. EIP-3009 signs the payer, payee, amount and nonce — not the resource. Verification checks the same fields. So two endpoints on the same network, token, payee and price are interchangeable: a challenge obtained at one can be paid at the other. Price is the only thing that separates a cheap service from an expensive one; give them different prices if that matters.
Settlement precedes the upstream call. That is the x402 contract — pay, then
serve — so an upstream that is down after settlement still costs the payer, and
so does any status the upstream answers with, a 400 or a 429 included. Only the
gateway's own refusals (404, 405, 413, its rate limits, and the json,
jsonFields, jsonOnlyFields, textOnlyMessages, and maxJsonArrayLength checks) come before the 402. The
gateway returns the transaction and X-PAYMENT-RESPONSE on that 502/504 so
there is something to reconcile with. buy gate probes every proxy service's
upstream with that service's own injectHeaders at boot, so a stale key is a
boot-time refusal naming the header rather than a paid 401.
The gateway does not refund. A host that does uses onComplete (below): the
buy API gateway logs every paid call that settled and was not served, records
a refund owed for it keyed by the settlement tx, and confirms the operator's
refund transfer on chain. See examples/paywall-refunds.mjs and "Paid but not
served" in docs/api-gateway.md in the repository.
Knowing what a paid request delivered
createGateway({ ..., onComplete }) calls back once per paid request, after
the response is written, with the settlement and the outcome:
createGateway({
config,
facilitator,
onComplete: (e) => {
// e.transaction: the settlement tx (null in mock mode)
// e.status, e.delivered, e.reason: what the caller got
// e.payer, e.payTo, e.asset, e.amount, e.endpoint, e.correlationId, ...
if (e.transaction && e.delivered) recordJob(e);
},
});delivered is true when the resource reached the caller, which includes an
upstream 4xx or 5xx relayed as is (the payer pays for those too, see above). It
is false when the gateway wrote its own 502/504 (reason:
upstream_unreachable, upstream_timed_out, upstream_redirected,
upstream_body_failed, upstream_body_timed_out,
upstream_response_too_large, serve_failed) or the caller hung up
(caller_disconnected, status: null). It never fires for a 402, a failed
verify or settle, a rate limit, a 503, or a free endpoint. The callback runs
without being awaited. If it throws or rejects, the gateway logs onComplete threw or
onComplete rejected and the response is unaffected.
