@aifinpay/gate
Put your own API behind AIFP-1, in your own process. No proxy, no traffic
through us, no round-trip per request.
An AI agent calls your endpoint. It has no account with you and no API key. It
gets a 402 that tells it exactly what the call costs and how to buy a batch,
pays on-chain from its own wallet, and retries with a signed receipt. Your gate
verifies that receipt locally and counts the prepaid units down.
npm install @aifinpay/gate
Requires Node 22+. Express is an optional peer (4 or 5); the core has no
framework dependency at all.
1. Sixty seconds
import express from "express";
import { aifpGate } from "@aifinpay/gate";
const app = express();
app.get(
"/api/search",
aifpGate({
merchantId: process.env.AIFP_MERCHANT_ID,
resource: "/api/search",
tier: "complex",
}),
(req, res) => {
res.json({ results: [], billed_units: req.aifp.weight });
}
);
app.listen(3000);
That is a working paywall. Before you ship it, read §3 — the default quota
store is per-process, and that is the one thing in this package that quietly
costs money if you get it wrong.
1b. Next.js, and the two ways it silently earns you nothing
Next.js runs middleware only on paths matched by config.matcher, and the gate
charges every request unless you tell it not to. Both defaults are reasonable on
their own and together they produce the two failure modes we see most often in
real integrations — each of which leaves the dashboard showing
paywall_enabled: true while the truth is very different.
aifpGate is Express middleware and does not run in middleware.ts. Use
createGate and turn its decision into a NextResponse yourself:
import { NextResponse, type NextRequest } from "next/server";
import { createGate, knownAiAgent } from "@aifinpay/gate";
const gate = createGate({
merchantId: process.env.AIFP_MERCHANT_ID!,
registry,
store,
shouldCharge: knownAiAgent,
});
export async function middleware(req: NextRequest) {
const result = await gate({
path: req.nextUrl.pathname,
header: (name) => req.headers.get(name) ?? undefined,
});
const res = result.ok ? NextResponse.next() : NextResponse.json(result.body, { status: result.status });
for (const [name, value] of Object.entries(result.headers)) res.headers.set(name, value);
return res;
}
export const config = {
matcher: [
"/api/:path*",
"/",
"/movies/:path*",
],
};
Without the matcher entry, the middleware never runs on that path. The
resource is registered, priced, and shown as enabled — and enforcement is zero.
Nothing reports this; the endpoint simply answers 200 forever.
Without shouldCharge, every request is charged, including human visitors
and Googlebot. On an API that is usually right — agents are the only callers.
On a page it is almost never right: you have put a 402 in front of your own
readers and your search ranking.
knownAiAgent returns true for the self-identifying AI crawlers — GPTBot,
ClaudeBot, PerplexityBot, CCBot, Bytespider, Google-Extended and the rest — plus
anything already speaking AIFP. A browser passes through free.
That is a decision about who is asked to pay, not enforcement. The
User-Agent is whatever the client says it is: a scraper that sends a browser's
User-Agent reads your pages free, exactly like a human. What it cannot do is
get a paid API route (no shouldCharge) without a receipt. So put the content
you must be paid for behind API routes that charge everyone, and use pages for
what you are content to show a browser. If you need pages enforced, the signal
has to come from something a client cannot simply claim — your edge's bot
verification, a signed agent request — added to the predicate below.
Extend it rather than replacing it when you have your own signal:
shouldCharge: (req) => knownAiAgent(req) || myEdgeStampedAiHeader(req),
A predicate that throws charges the request. Of the two wrong answers a
broken detector can give, a 402 to one human is visible and recoverable; a
crawler served free is silent and permanent.
robots.txt decides whether any of this runs at all
User-agent: *
Disallow: /
A well-behaved AI crawler reads that and leaves — before it ever reaches your
paywall. You cannot forbid a crawler and bill it at the same time; those are
opposite instructions to the same client, and robots.txt is the one it reads
first.
If you arrived here from "block the AI scrapers", this line is probably already
in your site and it guarantees zero revenue. Monetising AI traffic means
stop blocking, start charging — a business decision, not a config toggle.
2. Register your endpoints with your API key
Your routes live in code and change in pull requests. Their prices should too.
import { AifpMerchant, ResourceRegistry, aifpGate } from "@aifinpay/gate";
const merchant = new AifpMerchant();
await merchant.ensureResources([
{ route_pattern: "/api/search", type: "api", tier: "complex" },
{ route_pattern: "/api/lookup/*", type: "api", tier: "standard" },
{ route_pattern: "/api/report", type: "api", tier: "premium", name: "Generate report" },
{ route_pattern: "/api/health", type: "api", paywall_enabled: false },
]);
const registry = new ResourceRegistry({ merchant });
registry.start();
app.use(aifpGate({ merchantId: merchant.merchantId, registry }));
ensureResources is idempotent by route_pattern, so this belongs in your boot
script and is safe on every deploy. Re-running it converges instead of piling up
duplicates — which matters, because two records for the same path with different
weights would charge unpredictably.
These endpoints appear in your AiFinPay dashboard immediately. Not through a
sync job — there is nothing to sync. The SDK writes the same endpoint registry
the dashboard's Paywall Builder writes and the dashboard reads, so a route
registered from a deploy script and a route registered by clicking are the same
record, in the same table, on the same Insights charts. Register from code,
tune prices in the panel, or both.
One prerequisite: the merchant must be linked to a dashboard account. The
simplest path is to create the merchant in the dashboard, copy the secret from
there, and give it to the SDK. A merchant created purely over the API has no
owner, and the panel will not show you what your key registered under it.
The rest of the management surface, for scripts and internal tooling:
await merchant.listResources();
await merchant.getResource("res_…");
await merchant.createResource({ route_pattern: "/api/new", type: "api" });
await merchant.updateResource("res_…", { tier: "premium" });
await merchant.deleteResource("res_…");
await merchant.merchant();
await merchant.stats();
await merchant.activity(50);
await merchant.setWebhook("https://you.example/aifp-webhook");
Errors are typed, so a deploy script can tell a mistake from an outage:
AifpValidationError (your input, with the server's own hint), AifpAuthError
(wrong or rotated secret), AifpConflictError (duplicate route_pattern, and
it carries the existing resource_id). The secret is stored non-enumerably and
never appears in an error message, a stack, or JSON.stringify(merchant).
3. Choose a store
The gate meters a prepaid batch by counting billing units against the receipt.
The store is where that count lives, and it has exactly two rules:
incrBy is atomic and returns the value AFTER the add. The gate never
reads a counter and writes it back — it adds the call's weight and compares
what comes back. That is what makes overspend arithmetically impossible under
concurrency: whichever request receives the value that crosses the limit is
the one refused, exactly once.
- The TTL is set on the first write only. The counter must expire with
the receipt. A counter that outlives its receipt refuses paid calls; one that
expires early makes the whole batch spendable a second time.
Default — MemoryStore. Correct on one process, and only on one process.
It is genuinely atomic there (the read-add-write runs with no await in the
middle, so the event loop serializes it), it is capped so a flood of receipt ids
cannot exhaust memory, and it needs no infrastructure.
Its two hard limits: counters are lost on restart, and every process gets
its own copy. Under pm2 -i 4, node:cluster, or two pods behind a load
balancer, a 200-unit batch will serve up to 800 calls, and nothing in the
request path can detect it. If you run more than one process, use a shared
store.
Shared — redisStore. Pass your own client; this package declares no redis
dependency and will not touch your connection policy.
import Redis from "ioredis";
import { redisStore } from "@aifinpay/gate";
const store = redisStore(new Redis(process.env.REDIS_URL));
app.use(aifpGate({ merchantId, registry, store }));
It is one EVAL per call — increment and TTL in a single atomic script, and the
script repairs a counter that somehow lost its expiry. Redis errors propagate
rather than falling back to local memory: a silent downgrade would reopen the
overspend race the counter exists to close. onStoreError decides what happens
("closed" → 503, the default; "open" → serve and flag), and whatever it
decides is visible.
Anything else. DynamoDB, Postgres, Cloudflare KV — implement incrBy and
prove it before it meters real money:
import { assertStoreContract } from "@aifinpay/gate/testing";
await assertStoreContract(() => myStore(), { equal: assert.equal, ok: assert.ok });
Six cases, each of which maps to a way a merchant loses revenue. Adapter
skeletons for DynamoDB and Postgres are in the source of
@aifinpay/gate/stores.
4. What the gate does per request
| 1 | Resolve the resource and its weight — the registry record for this path, or the mount's resource/tier. An unregistered path stays paywalled, never free. | — |
| 2 | The resource is registered with paywall_enabled: false | 200, header AIFP-Paywall: off, no units spent |
| 3 | No AIFP-Receipt header | 402 + the full challenge |
| 4 | Receipt expired | 402 — receipt expired — prepay a new batch |
| 4 | Bad signature, issuer, or audience | 403 — receipt verification failed (signature/issuer/audience) |
| 4 | Our JWKS is unreachable and nothing is cached | 503 AIFP-503-METER — fails closed |
| 5 | Receipt is scoped to another path | 403 — names both the receipt's scope and the path |
| 6 | Single-use receipt replayed | 403 — receipt already spent (single-use) |
| 7 | used + weight > unit_quota | 402 — quota exhausted — prepay the next batch |
| 8 | Otherwise | 200, header AIFP-Quota-Remaining, req.aifp populated |
Verification is local and stateless: an Ed25519 signature checked against our
published JWKS, in your process, pinned to EdDSA. Your latency never depends
on ours. Pass jwks: { keys: [...] } to remove even the JWKS fetch (at the cost
of a redeploy when we rotate keys).
The 402 an agent sees:
{
"error": "AIFP-402",
"detail": "Payment Required — prepay a batch of requests and retry with the AIFP-Receipt header",
"protocol": "AIFP-1",
"merchant_id": "mrch_acme",
"resource": "/api/search",
"tier": "complex",
"unit_weight": 4,
"unit_price_usd": "0.002",
"min_requests": 50,
"protocol_fee_bps": 100,
"no_minimum_fee": true,
"how_to_pay": [
"POST https://api.aifinpay.io/v1/quote {\"merchant_id\":\"mrch_acme\",\"resource\":\"/api/search\",\"tier\":\"complex\"}",
"settle the quoted batch on-chain from your own wallet (order_id = quote_id)",
"POST https://api.aifinpay.io/v1/pay {quote_id, chain, asset, tx_ref} -> quota receipt",
"retry this request with header: AIFP-Receipt: <receipt JWT>"
]
}
And what your handler gets on a paid call:
req.aifp;
Pricing
Three fixed settings, per call. The displayed/quoted AIFP-1 price is the
gross amount paid by the agent. AiFinPay receives 1% of that gross amount
and the merchant receives 99% of gross before external network or settlement
costs. The 1% fee is not added on top of the displayed AIFP-1 price.
standard | $0.0005 | $0.000495 | $0.000005 | 1 |
complex | $0.002 | $0.00198 | $0.00002 | 4 |
premium | $0.005 | $0.00495 | $0.00005 | 10 |
AIFP-2/x402 is a separate route: the provider receives 100% of its
provider-defined price, while any AiFinPay AIFP-2 fee is payer-side/on-top. The
current AIFP-2 fee is 0%; a future non-zero fee requires a versioned AIFP-2
settlement profile and must not reduce the provider amount.
During the migration (as of 2026-08-23). The table above is the canonical
model and what the v1.3 settlement contract enforces on-chain. Polygon
mainnet still runs the previous splitter, whose immutable split is
98.99/1.00/0.01, and the backend grosses the total up from the merchant
amount to match it — so today an AIFP-1 merchant is made whole and the agent
pays slightly more than the displayed price. When v1.3 is deployed to
mainnet, the displayed price becomes exactly what the agent pays and the
merchant nets 99% of it. If you are integrating now, that is the one number
that moves under you; nothing in this package's API changes with it.
Weight is always price ÷ base price. That equality is what lets one prepaid
batch be spent across endpoints of different tiers and still drain at each
endpoint's real rate. Override per route with unit_weight when a call is
genuinely more expensive than its tier.
Options worth knowing
store | MemoryStore | Anything beyond one process. See §3. |
registry | — | Path-matched pricing from your registered endpoints. |
onStoreError | "closed" | "open" trades metering for availability, visibly. |
onEvent | — | 402 / serve / 403 / meter_error for your own metrics. |
allow | — | Your own veto, evaluated before any unit is metered, so a refused call costs the agent nothing. |
jwks | fetched | Pin the key set; removes all runtime network dependency on us. |
requireAgentMatch | false | Compares AIFP-Agent-Id to the receipt subject. Anti-accident, not anti-theft — the header is not authenticated. |
refundOnError | false | Give a unit back on a 5xx. Read the warning below first. |
refundOnError fires after your response has already gone out. If the agent
received a body, the refunded unit is a unit it got served for free — a slow
double-spend. Enable it only when your upstream fails before doing any work,
and understand that you are trading exact metering for generosity.
5. What this package does not do
- It does not hold money or keys. No custody, no funds movement. Agents
settle on-chain to your own wallet; this package verifies a receipt and counts.
- It does not enforce merchant policies. Free-unit allowances, per-agent
daily caps, blocklists and unlimited-access rules are evaluated by the hosted
AiFinPay gateway. A second implementation of a rule you edit in our dashboard
would be a second source of truth, and the two would disagree on the day it
mattered. Use
allow for rules that are genuinely yours.
- Self-hosted traffic does not populate the panel's funnel, geo and AI-client
charts. Those are written by the hosted gateway from traffic that passes
through it. Your registered endpoints still appear; the per-request analytics
do not. Use
onEvent to feed your own metrics.
- It cannot bind a receipt to a caller. A receipt is a bearer token. Anyone
who obtains the JWT can spend the batch. The honest guarantee is bounded
loss: a stolen receipt can spend at most the units the payer prepaid, and
the post-increment counter makes overspending impossible. It is not
non-repudiation, and
requireAgentMatch does not make it so.
Related
@aifinpay/agent — the other
side: an SDK for the agent that pays your paywall.
@aifinpay/mcp — MCP server
for agents that pay through an MCP host.
MIT © CoinSecurities (SECCO)
Agent instructions and route discovery
Use the payer skill
for an agent buying access, or the
merchant skill
for a site owner integrating the gate. Skills are instructions; they do not
install or enable a payment executor.
Starting with 0.3.3, both buildDiscoveryDocument and HTTP 402 challenges
include instructions_url, merchant_instructions_url and documentation_url.
A partner on an older package must update and redeploy to expose these fields.
The skill at a stable linked URL can then be updated independently; installed
skill copies need updating in the agent client.
Publish /.well-known/x402.json on each origin that serves gated resources.
The helper generates it from the resources array supplied by your app; it
does not inspect your router or register that catalog in a central database.
Use the same resource configuration for discovery and gates to prevent drift.
Keep discovery and an API parameter catalog publicly readable. Link them from
/llms.txt using the same origin (or relative URLs), including on staging.
A production URL in a staging llms.txt can send agents to a missing catalog.
The discovery file lists resource prices and scopes. A fresh quote supplies
settlement terms; discovery is neither a receipt nor proof that a particular
client can execute the offered route.