Quickstart
A paid endpoint and a client that pays it, in about five minutes.
You need Node 20+ and a wallet holding the token you want to charge in. You do not need to run a facilitator — a public one is live.
1. Gate a route
npm install @loxley/server
import { loxley } from '@loxley/server'
app.get('/v1/report/:symbol', loxley({
price: 10_000n, // 0.01 USDC, in base units
payTo: '0xYourTreasury…',
asset: USDC,
assetName: 'USD Coin', assetVersion: '2',
network: 'eip155:46630',
settlement: '0x54A49180CFb0f1573f8d6227eA36aDE583Cf0743',
facilitator: 'https://loxley-facilitator-production.up.railway.app',
}), (req, res) => {
res.json({ paidBy: req.loxley!.payer })
})
2. Pay for it
npm install @loxley/client
import { createLoxleyFetch } from '@loxley/client'
const pay = createLoxleyFetch({
account,
chain: robinhoodChainTestnet,
policy: { maxAmountPerRequest: 50_000n }, // required — see below
})
const res = await pay('https://api.example.com/v1/report/AAPL')
That is the whole integration. The handler is unchanged; by the time it runs, the
payment has settled on-chain and req.loxley.receipt.txHash is the proof.
Concepts
| Payer | Owns the funds and signs the intent. Usually an agent; can be a person with a browser wallet. |
| Merchant | The HTTP server gating a resource. Never holds a key beyond its payout address. |
| Facilitator | Verifies signatures and broadcasts settlement, paying gas. Untrusted — see the security model. |
| Intent | The signed authorization to move funds for one specific resource. |
| Receipt | Proof settlement happened: transaction hash, block, fee. |
The challenge
A merchant answers with 402 and a JSON body describing how to pay.
{
"lx402": "1",
"resource": "GET https://api.example.com/v1/report/AAPL",
"resourceHash": "0x54398e3e…",
"expiresAt": 1785111596,
"accepts": [{
"scheme": "exact", // or "upto" for metered
"network": "eip155:46630", // CAIP-2
"settlement": "0x54A49180…",
"asset": "0xB3633542…",
"assetSymbol": "USDC", "assetDecimals": 6,
"assetName": "USD Coin", "assetVersion": "2",
"amount": "10000",
"payTo": "0xb3CF79a5…",
"salt": "0x50617cd8…",
"validAfter": 0,
"validUntil": 1785111596,
"sources": ["authorization", "permit2", "balance", "allowance"],
"facilitator": "https://…"
}]
}
saltis 32 random bytes, fresh per challenge. It is what makes each intent single-use.validUntilshould be short — 30 to 120 seconds. It is the window in which a signature is live.assetNameandassetVersionare required when offeringauthorization: that signature is made against the token's own EIP-712 domain, and without them a client would need its own RPC node purely to read them.
The payment
The client retries the identical request with a base64url header.
X-Loxley-Payment: <base64url(JSON)>
{
"lx402": "1",
"scheme": "exact",
"network": "eip155:46630",
"source": "authorization",
"settlement": "0x54A49180…",
"intent": {
"payer": "0x61D5e0d9…",
"payTo": "0xb3CF79a5…",
"asset": "0xB3633542…",
"amount": "10000",
"salt": "0x50617cd8…",
"validAfter": 0,
"validUntil": 1785111596,
"resourceHash": "0x54398e3e…",
"source": 2
},
"signature": "0x…"
}
Every field of intent must equal the corresponding field of the chosen
requirement. A merchant rejects any payload that deviates — especially
payTo, asset, amount and resourceHash.
The response carries X-Loxley-Receipt, also base64url JSON.
Settlement sources
Four ways the money can move. The payer chooses; the merchant only advertises what its facilitator can settle.
authorization — gasless
For EIP-3009 tokens such as USDC. The payer signs only the token's
TransferWithAuthorization message — but its nonce is set to the LX-402
intent digest, so that single token-domain signature transitively commits to the
merchant, the amount and the resource. Tamper with any of them and the derived nonce
changes, so the token itself rejects the signature.
The payer needs no approval and no native token at all.
permit2 — any ERC-20
The payer approves the canonical Permit2 contract once, ever; every payment after
that is a signature. LX-402 folds a
LoxleyWitness(payTo, resourceHash, salt) into the Permit2 message, so a
Permit2 payment keeps resource binding — a bare permit commits the amount but says
nothing about who is paid or what is bought.
balance — prefunded
Deposit into the settlement contract, then each payment is an internal debit. The cheapest path per payment, and the right choice for high-frequency agent billing. A withdrawal cooldown means a payer cannot pull funds out from under a payment already in flight.
allowance — universal fallback
A standard ERC-20 approval to the settlement contract, then a signature per payment. Works with any token, needs an approval per spender.
Metered billing
Under scheme: "upto" the quoted amount is a
ceiling. The merchant settles for what the work actually cost.
import { loxleyMetered } from '@loxley/server'
app.post('/v1/complete', loxleyMetered({
maxPrice: 20_000n, // the ceiling the payer authorises
payTo, asset, network, settlement, facilitator,
}), async (req, res) => {
const { tokens } = await runModel(req.body)
req.loxleyMeter!.charge(BigInt(tokens) * PRICE_PER_TOKEN)
res.json({ tokens })
})
Settlement is necessarily deferred — the cost is unknown until the work is done. The payment is verified against the ceiling before the handler runs, so a payer who cannot cover the worst case never gets served, and settled once the response is sent.
- The ceiling is enforced on-chain. Charging above it reverts with
ChargedExceedsAuthorized. - The protocol fee is taken on what is charged, never on the ceiling.
- Metered intents use distinct source values, so an
exactsignature can never be settled for a lesser figure — a payer quoted a fixed price always gets it. - Reporting no usage charges nothing. A metering bug should cost the payer zero, not their maximum.
authorizationcannot be metered: EIP-3009 moves a fixed value with no partial semantics. Meterable sources arepermit2,balanceandallowance.
Resource binding
Every intent commits to a hash of the resource it pays for:
canonical = "<METHOD> <scheme>://<host><path>" // no query, no fragment
resourceHash = keccak256(utf8(canonical))
A signature captured for a cheap endpoint therefore cannot be presented at an expensive one. The merchant recomputes the hash from the incoming request and compares before serving; the contract emits it, giving every payment an auditable link to what was bought.
Query strings are excluded so that cache-busting or pagination parameters cannot invalidate a quoted price. Merchants pricing per-query express that with separate challenges, which already carry distinct salts.
origin on the middleware. Otherwise
the hash the merchant computes will not match what the client signed.
Gating a route
Express
import { loxley } from '@loxley/server'
app.get('/v1/report/:symbol', loxley({ price: 10_000n, … }), handler)
Hono, Next, Bun, Deno, Workers
These all speak the Web Fetch API, so one guard covers them and none is a dependency.
import { loxleyHono, withLoxley } from '@loxley/server'
// Hono
app.get('/v1/report', loxleyHono({ price: 10_000n, … }), (c) =>
c.json({ paidBy: c.get('loxley').payer }))
// Next App Router
export const GET = withLoxley({ price: 10_000n, … }, async (req, { payer }) =>
Response.json({ paidBy: payer }))
Settlement timing
- settle-before-serve (default) withholds the response until the transaction is included. Costs one block of latency; eliminates served-but-unpaid.
- after-response serves first and settles behind it. Lower latency, but only sound for the
balancesource — protected by the withdrawal cooldown — or where the loss is bounded and acceptable.
Paying for one
Three clients, each written against the specification.
TypeScript
const pay = createLoxleyFetch({
account, chain,
policy: {
maxAmountPerRequest: 50_000n,
maxTotalPerSession: 1_000_000n,
allowedAssets: [USDC],
allowedNetworks: ['eip155:46630'],
},
preferSources: ['permit2'], // optional
})
Python
from loxley import LoxleyClient, SpendPolicy
client = LoxleyClient(KEY, SpendPolicy(max_amount_per_request=50_000))
response = client.get("https://api.example.com/v1/report/AAPL")
print(client.last_receipt["txHash"])
Go
client, _ := loxley.New(key, &loxley.SpendPolicy{MaxAmountPerRequest: 50_000})
resp, err := client.Get("https://api.example.com/v1/report/AAPL")
All three are pure HTTP — none dials a node. Everything needed to sign arrives in the challenge.
Browser paywall
When a request's Accept includes text/html, the middleware
serves a payment page instead of a JSON challenge. Agents never send that header, so
the two audiences separate cleanly. Disable with paywall: false.
The page is self-contained — no bundler, no external script, no crypto library. That
works because permit2 is signed with eth_signTypedData_v4, so
the wallet does the EIP-712 hashing. The authorization source cannot be
used in a browser: deriving its nonce needs a client-side keccak.
Discovery
A facilitator can index paid endpoints, so agents can find things to pay for instead of being told about them out of band.
# register — the facilitator verifies before listing curl -X POST $FACILITATOR/discovery/register \ -H 'content-type: application/json' \ -d '{"resource":"https://api.example.com/v1/report"}' # search curl "$FACILITATOR/discovery/resources?maxAmount=10000&network=eip155:46630"
Registration is self-verifying. The facilitator fetches the URL itself, requires a
real 402 whose resourceHash matches the URL it came from, and
indexes the terms the endpoint actually quoted. Nothing in a listing comes from
the registration request except the URL — you can nominate an endpoint, but you cannot
lie about its price. Endpoints returning 200 are rejected.
Listings are advisory. A client must still validate the challenge it receives when it actually requests the resource; prices can change between indexing and payment.
Public facilitators
Point a merchant at one of these and you host nothing.
| Facilitator | Networks | Schemes | Fee | Status |
|---|---|---|---|---|
loxley-facilitator-production.up.railway.app |
Robinhood Testnet (46630) | exact, upto | 0.30% | health · supported |
One entry, and it is testnet — this list is honest rather than impressive. A facilitator holds no user funds and cannot redirect a payment, so running one is low-risk and anyone can: see deploying. Ask a facilitator what it supports rather than assuming:
curl https://<facilitator>/supported
{ "networks": ["eip155:46630"], "schemes": ["exact","upto"],
"sources": ["authorization","permit2","balance","allowance"], "feeBps": 30 }
Facilitator API
| Endpoint | Purpose |
|---|---|
GET /health | Per-chain RPC reachability and gas balance. Returns 503 when a wallet is too low to settle. |
GET /supported | Networks, schemes, sources, settlement addresses, fee. |
POST /verify | Off-chain checks plus a simulated call. No gas, no state change. |
POST /settle | Broadcasts and waits for inclusion. Idempotent per intent hash. |
GET /discovery/resources | Search the index. |
POST /discovery/register | Ask the facilitator to verify and index a resource. |
Deploying your own facilitator
One process serves every configured chain. It holds no user funds — only gas.
LOXLEY_CHAINS=4663,8453,42161 LOXLEY_SETTLEMENT_ADDRESS=0x54A49180CFb0f1573f8d6227eA36aDE583Cf0743 LOXLEY_RPC_URL_8453=https://… # public RPCs are rate-limited FACILITATOR_PRIVATE_KEY=… # hot wallet, gas only DATABASE_URL=… # required before scaling past one replica
- Fund the gas wallet on every chain you configure. A facilitator with no gas on Arbitrum fails every Arbitrum payment while happily settling on Base.
- One replica is the default and the only safe setting without Postgres: the claim that stops two replicas broadcasting the same intent lives in the database.
- Broadcasts are serialised per chain to avoid nonce collisions, so sustained throughput is about one settlement per round-trip per chain. Past that, run a pool of gas wallets.
Security model
The facilitator is untrusted
It pays gas and can refuse to settle. It can never redirect funds, change an amount,
or settle twice — payTo and amount live inside the payer's
signature, and replay is rejected on-chain by intent digest. Its only real powers are
censorship and burning its own gas.
Replay
Intent hashes are marked used on-chain at settlement. The authorization
path additionally inherits the token's own nonce registry. A facilitator-side cache is
a latency optimisation, never the defence.
Funds cannot be trapped
withdraw() is deliberately not gated by the pause switch. An operator
can stop new settlements but can never freeze user deposits.
Front-running
Settlement transactions are public before inclusion. Copying one is pointless: funds
move only to the payTo inside the signed intent, so a front-runner merely
pays gas to complete someone else's payment.
Fees
A contract-level feeBps, hard-capped at 5% in bytecode, deducted from
the charged amount. The payer is never charged more than they signed for.
Status & limits
Verified against a public chain
All four sources, metered billing, the Permit2 witness (tested against real Permit2 bytecode etched from chain), deterministic addressing across six chains, three client languages, discovery, and the browser paywall — each confirmed by reading chain state rather than trusting an HTTP status.
Known limits
- Throughput is roughly one settlement per round-trip per chain.
- Rate limiting is per-process, so N replicas allow roughly N times the configured limit.
after-responsesettlement is fire-and-forget. If the merchant process dies between serving and settling, that payment is lost — the intent stays valid untilvalidUntil, but nothing re-submits it.- Discovery listings are advisory and can go stale.
- The paywall cannot use
authorization, so a browser payer needs a one-time Permit2 approval.
Resolved, not implemented
ERC-7710 delegation — an account-layer concern, not a protocol one.
A real MetaMask DelegationManager v1.3.0 is deployed on Robinhood Chain
testnet, so it was never blocked on a dependency. The open question — where per-payment
binding lives under a standing authorization — resolves cleanly: the delegate redeems
its own delegation, then pays with any existing source. The payer is the agent, so every
intent is signed and resource-bound exactly as today. Delegation bounds the total; the
intent binds the payment.
A settlement-contract entry point is deliberately absent. A delegation may name
address(0xa11) to mean "any delegate", and a permission context is not
secret — so a contract that redeemed on a caller's behalf and credited
msg.sender would let the first observer of an open delegation credit
themselves with someone else's funds. Caveat enforcers cannot prevent it: they constrain
the on-chain execution and cannot see Loxley's credit accounting.
Solana — specified, not built. CAIP-2 solana:
identifiers, a canonical byte encoding replacing EIP-712 that keeps
resourceHash so resource binding survives, a settlement program using
marker PDAs for replay, and a clear answer on sources: balance and
allowance carry over, authorization and permit2
cannot. Gaslessness arrives more simply, since Solana separates the fee payer natively.
Realistically a separate project with its own audit.