Charge for Traefik routes with x402
A Traefik middleware plugin. It answers 402 Payment Required on the URLs you choose. When the client pays, the plugin verifies and settles the payment through an x402 v2 facilitator, then serves the response.
Install
Enable the plugin in the Traefik static configuration, then attach the middleware to a router.
experimental:
plugins:
x402:
moduleName: github.com/lukaszraczylo/traefik-x402
version: v0.2.1 # use the latest release tag
http:
middlewares:
pay:
plugin:
x402:
facilitatorURL: https://x402.org/facilitator
accepts:
- network: eip155:84532
amount: "10000" # 0.01 USDC in atomic units
asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e"
payTo: "0x209693Bc6afc0C5328bA36FaF03C514EF312287C"
extra: { name: USDC, version: "2" }
exact: [/report]
prefixes: [/premium/]
suffixes: [.pdf]
An unpaid request to /premium/data now returns 402 with a PAYMENT-REQUIRED header. The header holds base64 of this JSON, and the body holds the same JSON:
{"x402Version":2,"error":"PAYMENT-SIGNATURE header is required",
"resource":{"url":"https://api.example.com/premium/data"},
"accepts":[{"scheme":"exact","network":"eip155:84532","amount":"10000",
"asset":"0x036CbD53842c5426634e7929541eC2318f3dCF7e",
"payTo":"0x209693Bc6afc0C5328bA36FaF03C514EF312287C",
"maxTimeoutSeconds":60,"extra":{"name":"USDC","version":"2"}}]}
The client signs a payment and retries with it in PAYMENT-SIGNATURE. The plugin never touches a wallet or a chain. The facilitator does that.
Choose which URLs to protect
A rule selects a request when the path matches any entry of any list.
| Field | Matches when the path | Example |
|---|---|---|
exact | equals the entry | /report |
prefixes | starts with the entry | /premium/ matches /premium/a/b |
suffixes | ends with the entry | .pdf matches /docs/manual.pdf |
- The plugin tests the decoded path and its cleaned form, so
/free/../premium/xis still protected. - Use
rulesfor different prices on different paths. The first matching rule wins. methodslimits a rule to some HTTP methods. CORS preflight (OPTIONS) is never charged by default.ignoreCase: trueturns off case sensitivity.
Offer several assets for one URL
Each entry in accepts is one payment option. The client picks one. The plugin accepts the payment only when the client's echo equals one of your options exactly. Token values below were read from Base mainnet on 2026-10-03.
accepts:
- network: eip155:8453 # USDC on Base
amount: "10000" # 0.01 USDC, 6 decimals
asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
payTo: "0x209693Bc6afc0C5328bA36FaF03C514EF312287C"
extra: { name: USD Coin, version: "2" }
- network: eip155:8453 # EURC on Base
amount: "10000" # 0.01 EURC, 6 decimals
asset: "0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42"
payTo: "0x209693Bc6afc0C5328bA36FaF03C514EF312287C"
extra: { name: EURC, version: "2" }
- network: solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp
amount: "10000"
asset: EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v
payTo: "<your Solana address>"
amount is in the token's atomic units, so work it out from decimals(). For an EIP-3009 token, extra carries the EIP-712 domain name and version of the contract. A token without EIP-3009 needs the Permit2 method, which extra.assetTransferMethod selects in the x402 EVM specification. Whether a facilitator offers it is the facilitator's decision. The plugin passes extra through.
Talk to the facilitator
Credentials without a proxy
Values in facilitatorHeaders and in facilitatorAuth can be env:NAME or file:/path references, so secrets stay out of the Traefik configuration. For the Coinbase Developer Platform facilitator, the plugin signs the per-request JWT itself:
facilitatorURL: https://api.cdp.coinbase.com/platform/v2/x402
facilitatorAuth:
type: cdp
keyID: env:CDP_API_KEY_ID
keySecret: file:/run/secrets/cdp_api_key_secret
It signs with EdDSA for an Ed25519 key and ES256 for a P-256 PEM key. One token is cached for each request URI and reused until 30 seconds before it expires.
Check what the facilitator supports
| supportedCheck | Behaviour |
|---|---|
off | No check. |
warn (default) | Background check after start-up. Logs options the facilitator does not list. |
strict | Checks before start-up. An unreachable facilitator or an unsupported option stops the plugin. Fills extra.feePayer from the facilitator for options that have none, which Solana needs. |
Strict mode ran against https://x402.org/facilitator on 2026-10-03: it accepted Base Sepolia and Solana devnet, filled the Solana fee payer, and rejected Base mainnet, which that facilitator does not list.
Decide who pays
Existing callers can keep working while agents pay. Three options, per rule:
exemptHeadersandexemptUserAgentslet a request through without payment. For exampleSec-Fetch-Mode, which browsers always send, andGooglebot. This is a filter, not security: a client can add the header.challengeStatuses: [401]sends an unpaid request to the upstream first. A listed status turns into402with the payment requirements. Other responses pass through, so the upstream decides who must pay, for example "no valid API key".payerHeaderandpaidHeaderstell the upstream that a payment happened. Give a paid header a secret value so the upstream can tell it from a forged one.
Settle after the upstream answers
| settlement | Order | Upstream error |
|---|---|---|
after (default) | verify, upstream, settle | Not charged |
before | verify, settle, upstream | Charged |
In after mode the plugin settles when the upstream commits to a status below 400, before the first body byte reaches the client. Bodies stream without buffering. If the facilitator reports a failed settlement, the plugin replaces the response with 402 and a PAYMENT-RESPONSE header that describes the failure. If the settle call itself fails, the plugin answers 500 with unexpected_settle_error.
A replay guard rejects a second request that carries a payment already in use. It closes the gap between verification and settlement. The guard is in memory and belongs to one Traefik instance.
Limits
WebSocket
Traefik runs plugins in Yaegi, which hides Flusher and Hijacker from a wrapped response writer. A request with an Upgrade header to a protected path gets 501 with upgrade_not_supported. Upgrade requests to unprotected paths pass through. Server-sent events work: a request with Accept: text/event-stream settles before the upstream runs.
- The plugin converts no prices. You write each
amountin atomic units. - The plugin is not a facilitator and offers no discovery endpoint.
- The replay guard does not span replicas. The chain and the facilitator guard across them.
- No test has called the CDP service, because that needs a CDP account. Tests check the signatures with the matching public key.
Measured
Benchmarks ran on an Apple M4 Max (darwin/arm64, Go 1.27.1) with compiled code. Traefik runs the plugin in Yaegi, which is slower. Command: go test -run '^$' -bench . -benchmem -count=3 .
| Case | Time | Allocations |
|---|---|---|
| Request outside every rule | 33 ns | 0 |
| Rule lookup, 9 entries | 23 ns | 0 |
Build a 402 with two options | 0.80 to 0.83 µs | 15 |
| Decode a payment header | 1.6 µs | 11 |
| Replay guard claim and release | 50 ns | 0 |
| Paid request against a loopback facilitator | 74 to 76 µs | 223 |
The paid-request figure includes the in-process facilitator and two loopback HTTP calls. A real facilitator adds its own round trips.
Tests
- Unit tests cover 97.4% of statements and pass with the race detector.
- A Yaegi v0.16.1 harness loads the plugin the way the Traefik Plugin Catalog does and drives payment flows through the interpreted handler.
- An end-to-end test runs the plugin in
traefik:v3.7against a mock facilitator.