# L402 (Lightning HTTP 402) API Key proxy Aperture is your portal to the Lightning-Native Web. Aperture is used in production today by [Lightning Loop](https://lightning.engineering/loop), a non-custodial on/off ramp for the Lightning Network. Aperture is a HTTP 402 reverse proxy that supports proxying requests for gRPC (HTTP/2) and REST (HTTP/1 and HTTP/2) backends using the [L402 Protocol Standard][l402]. L402 is short for: the Lightning HTTP 402 protocol. L402 combines HTTP 402, macaroons, and the Lightning Network to create a new standard for authentication and paid services on the web. L402 is a new standard protocol for authentication and paid APIs developed by Lightning Labs. L402 API keys can serve both as authentication, as well as a payment mechanism (one can view it as a ticket) for paid APIs. In order to obtain a token, we require the user to pay us over Lightning in order to obtain a preimage, which itself is a cryptographic component of the final L402 token The implementation of the authentication token is chosen to be macaroons, as they allow us to package attributes and capabilities along with the token. This system allows one to automate pricing on the fly and allows for a number of novel constructs such as automated tier upgrades. In another light, this can be viewed as a global HTTP 402 reverse proxy at the load balancing level for web services and APIs. [l402]: https://github.com/lightninglabs/L402 ## Payment schemes Aperture speaks two payment protocols, and a single `402` response can carry an offer for each so the buyer takes whichever door it understands. **L402** is the default and is described above: a macaroon plus the preimage of the invoice that paid for it. **The Payment HTTP Authentication Scheme** ([`draft-httpauth-payment-00`][mpp], co-authored by Stripe and Tempo) is enabled with `--authenticator.enablempp`. It supports two intents. A *charge* buys a single request, and a *session* (`--authenticator.enablesessions`) holds a deposit that many requests draw against, refunding whatever is left when the buyer closes it. Sessions and charge consumption records both need a real database, so `--dbbackend` must be `sqlite` or `postgres`; etcd is refused at startup. Each Payment challenge binds its HMAC to the exact resource that Aperture priced. For a dynamic-price service, that resource includes the request path. A credential issued for one resource is refused when a client presents it for another. A request authenticated with a Payment credential carries no L402 identity: Aperture removes any L402 it also presents, in `Authorization` or in the `Macaroon` and `Grpc-Metadata-Macaroon` headers, before rate limiting, metering or forwarding it, since nothing verified that token. [mpp]: https://datatracker.ietf.org/doc/draft-httpauth-payment/ ## Public paths `authwhitelistpaths` allows anonymous access within an authenticated service. L402 authentication is checked but not required. Successful requests retain the usual header-forwarding behavior. If authentication fails, Aperture removes `Authorization`, `Macaroon`, and `Grpc-Metadata-Macaroon` before forwarding the request anonymously. The same cleanup applies to anonymous freebie and zero-price fallbacks. On services with authentication enabled, Aperture always removes client-supplied `Grpc-Metadata-Authorization`; grpc-gateway would otherwise translate it into unvalidated `authorization` metadata. Public paths only validate L402. They do not execute MPP payment actions, issue payment challenges, or incur metered charges. For dynamically priced services, a valid token for another resource in the same service can establish identity on a public path. Protected paths still require a token for the exact resource. Credential cleanup removes the entire `Authorization` header, including Bearer and Basic values. Use service-level `auth: "off"` when the backend owns authentication; that setting bypasses both validation and credential removal. On any authenticated request, Aperture removes L402 macaroons from the `Macaroon` and `Grpc-Metadata-Macaroon` headers unless the verified credential was read from that header, even ones naming the verified token, since nothing checked their signatures or caveats. Other macaroons, such as lnd's, are forwarded unchanged. ## Request paths Aperture first cleans the request path as the client escaped it: it drops empty segments and `.` segments, written plainly or percent-encoded, and keeps every other segment exactly as sent. It then matches services, whitelisted paths, prices and rate limits against the decoded form of the cleaned path, and forwards the escaped form, so a percent-encoded character reaches the backend as the client sent it. Decoding happens after cleaning, so separators introduced by decoding are preserved in the path used for matching. A path with a `..` segment is refused with 400, and so is one containing a character that some backends normalize in a way that would change which resource it names. A request target that is not an absolute path (anything other than `OPTIONS *`) is refused as well. A percent-encoded separator such as `%2F` is a path separator to Aperture, but some backends (grpc-gateway, chi, Go's `ServeMux` and others) treat it as part of a single segment. Write service and whitelist patterns so they still match when a segment is encoded this way, for example by matching a prefix rather than counting segments. Dynamic-price tokens are named from the decoded path, so the encoded and decoded spellings of a path map to the same token. ## Metered pricing Aperture can sell one request per payment, or it can sell a prepaid bundle of usage and draw it down as requests flow. The second mode exists because a fixed price per request is the wrong unit for LLM inference: one completion costs the seller a few dozen upstream tokens and the next costs four thousand, and the seller only learns which after the response has been served. Metering splits the two apart. The payment happens once, up front, for a bundle at a known price. The accounting happens per request, after the fact, from the usage the upstream actually reported. Aperture holds no pricing or balance state of its own; it calls out to a price server over gRPC (`pricesrpc`), configured per service with `dynamicprice` and `metered: true`. The reference price server, `meterd`, ships in this repo and keeps its state in a JSON file, which is enough for development. Production deployments embed `meterd.Server` behind their own `Store` and `RateSource`. Streamed responses work the same way: aperture keeps a bounded tail of the response so it can read the usage out of the final SSE chunk, and applies `writetimeout` as a rolling idle window rather than an absolute deadline so a generation lives as long as it keeps flowing. Size that window to the worst gap between chunks, not to the total length of the stream. [`docs/metering.md`](docs/metering.md) covers all of this in full: the bundle lifecycle, how L402 and each Payment intent attribute usage to a balance, what changes under streaming, and the configuration reference. ## Installation / Setup **lnd** * Make sure `lnd` ports are reachable. **aperture** * Compilation requires Go `1.25` or later. * To build both `aperture` and `aperturecli`, run `make build`. To install them into your `$GOPATH/bin`, run `make install`. * Make sure port `8081` is reachable from outside (or whatever port you choose). * Make sure there is a valid `tls.cert` and `tls.key` file located in the `~/.aperture` directory that is valid for the domain that aperture is running on. Aperture doesn't support creating its own certificate through Let's Encrypt yet. If there is no `tls.cert` and `tls.key` found, a self-signed pair will be created. * If Aperture is behind a TLS-terminating load balancer/ingress, make sure the load balancer's ALPN policy advertises `h2` (for example, AWS NLB `HTTP2Preferred` or `HTTP2Only`). Some gRPC clients fail with `missing selected ALPN property` if no ALPN protocol is negotiated. On AWS NLB, the default ALPN policy is `None`, which does not negotiate ALPN. If you use TCP passthrough instead of TLS termination at the load balancer, Aperture negotiates ALPN directly. * Make sure all required configuration items are set in `~/.aperture/aperture.yaml`, compare with `sample-conf.yaml`. * Start aperture without any command line parameters (`./aperture`), all configuration is done in the `~/.aperture/aperture.yaml` file. ## Upgrading Changes that need attention when upgrading from an earlier version: * Payment credentials created by an older Aperture carry no resource binding and are refused after the upgrade, even if the invoice was paid. Clients must fetch and pay a fresh challenge. Account for outstanding Payment challenges when scheduling the upgrade. * Service names can no longer contain `/` or `=`. A slash could overlap the token namespace of another dynamic-price service, and an equal sign cannot be read back from the condition of a capabilities caveat. Startup fails on such a service, including one stored in the database, where the admin API cannot rename it once Aperture refuses to start. Rename it before upgrading. * A service's `auth` value must be `on`, `off`, `true`, `false`, or `freebie N` with N from 1 to 65535; anything else now fails startup. An unrecognized value such as `no` used to make the service free. The admin API used to accept and store a larger freebie count, so correct such a value before upgrading. * A dynamic-price token whose resource name (its `service_name`) contains a `,` may have been minted authorizing more than the one resource. Such tokens are no longer minted, but upgrading does not invalidate existing ones; revoke them. With a `sqlite` or `postgres` backend, list tokens with `GET /api/admin/tokens` and revoke each one whose `service_name` contains `,` with `DELETE /api/admin/tokens/{token_id}`. Only settled tokens are listed, and settlements are recorded only while the admin API is enabled; enabling it reconciles earlier payments at the next startup. Tokens minted without a transaction record, by the etcd backend or by versions before v0.5.0, cannot be found this way. * A dynamic-price service, or a static one whose price was not configured or was changed through the admin API, minted tokens without its `timeout`, `capabilities` and `constraints`, so with a timeout set they never expired. New tokens carry these restrictions, but existing ones do not; revoke them through the admin API as described above. A dynamic-price token's `service_name` is the service name followed by the request path. * On a service with a `timeout`, a resource name containing `=` cannot carry a readable timeout caveat, so no token is minted for it. * Services that share a name also share their tokens, so startup now fails if they set different `timeout`, `capabilities` or `constraints`. * Request paths are now cleaned as sent, with `.` segments and repeated slashes removed, before they are matched and forwarded, and percent-encoded characters keep their encoding. A path with a `..` segment is refused with 400, and so is a request target that is not an absolute path (other than `OPTIONS *`). * A path containing a character that some backends normalize in a way that would change which resource it names is refused with 400. ## Admin API Aperture ships with an optional gRPC and REST admin API for managing services at runtime, querying transaction history, and monitoring revenue. Enable it by adding an `admin` section to your config: ```yaml admin: enabled: true macaroonpath: "/path/to/admin.macaroon" # defaults to ~/.aperture/admin.macaroon ``` On first startup Aperture generates a random root key and writes an admin macaroon to the configured path. All admin endpoints (except health) require this macaroon for authentication, passed as hex-encoded gRPC metadata or an HTTP header. The admin API exposes ten RPCs covering the full lifecycle of the proxy: | RPC | REST | Description | |-----|------|-------------| | `GetHealth` | `GET /api/admin/health` | Health check (no auth required) | | `GetInfo` | `GET /api/admin/info` | Server info: network, listen address, TLS status | | `ListServices` | `GET /api/admin/services` | List all proxied backend services | | `CreateService` | `POST /api/admin/services` | Register a new service with pricing and auth | | `UpdateService` | `PUT /api/admin/services/{name}` | Update service config (pricing, address, auth) | | `DeleteService` | `DELETE /api/admin/services/{name}` | Remove a service | | `ListTransactions` | `GET /api/admin/transactions` | Query L402 transactions with filters | | `ListTokens` | `GET /api/admin/tokens` | List issued L402 tokens | | `RevokeToken` | `DELETE /api/admin/tokens/{token_id}` | Revoke a token | | `GetStats` | `GET /api/admin/stats` | Revenue statistics with per-service breakdown | Services created through the admin API are persisted to the database and survive restarts. Changes take effect immediately — the proxy's routing table is updated in-place, so you can adjust pricing or swap backends without downtime. See [docs/admin-api.md](docs/admin-api.md) for full configuration details. ## Dashboard When built with the `dashboard` build tag (`make build-withdashboard`), Aperture embeds a Next.js web dashboard served at the root path. The dashboard provides a visual interface for everything the admin API exposes: service management, transaction history with filtering and pagination, revenue charts, and token administration. The dashboard communicates with the admin API through a server-side proxy that injects the macaroon automatically, so no client-side credentials are needed. Access is restricted to loopback connections for security. See [docs/dashboard.md](docs/dashboard.md) for setup and screenshots. ## CLI (`aperturecli`) `aperturecli` is a standalone command-line tool for the admin gRPC API. It connects directly over gRPC (not REST) and authenticates with the same admin macaroon. ```bash # Install make install # Basic usage aperturecli --insecure health aperturecli --insecure services list aperturecli --insecure services create --name myapi --address 127.0.0.1:8080 --price 100 aperturecli --insecure services update --name myapi --price 500 aperturecli --insecure stats ``` The CLI is designed to work well for both humans and AI agents. When stdout is a TTY it renders tables; when piped it emits JSON. Errors carry semantic exit codes (connection failure, auth failure, not found, etc.) and structured JSON on stderr, so scripts and agents can branch on the exit code without parsing error text. A `schema` command dumps the full command tree as machine-readable JSON for agent discovery: ```bash aperturecli schema --all ``` All mutating commands support `--dry-run`, which prints the request that would be sent without actually calling the server. See [docs/cli.md](docs/cli.md) for the full command reference. ### MCP Server `aperturecli` also embeds an MCP (Model Context Protocol) server, started with `aperturecli mcp serve`. This exposes every admin RPC as a typed tool over stdio JSON-RPC, letting agent frameworks like Claude Code manage Aperture directly. Add it to your MCP config: ```json { "mcpServers": { "aperture": { "command": "aperturecli", "args": ["--insecure", "mcp", "serve"] } } } ``` See [docs/mcp-server.md](docs/mcp-server.md) for setup details. ## Rate Limiting Aperture supports optional per-endpoint rate limiting using a token bucket algorithm. Rate limits are configured per service and applied based on the client's L402 token ID for authenticated requests, or IP address for unauthenticated requests. Anonymous requests still obey IP-based limits when the pricer returns zero, including after a service's freebie allowance is exhausted. On whitelisted paths, verified L402 tokens get their own rate-limit bucket. Anonymous requests and services with `auth: "off"` keep IP-based limits. Requests authenticated with a Payment credential are limited by IP address. ### Features * **Token bucket algorithm**: Allows controlled bursting while maintaining a steady-state request rate. * **Per-client isolation**: Each L402 token ID or IP address has independent rate limit buckets. * **Path-based rules**: Different endpoints can have different rate limits using regular expressions. * **Multiple rules**: All matching rules are evaluated; if any rule denies the request, it is rejected. This allows layering global and endpoint-specific limits. * **Protocol-aware responses**: Returns HTTP 429 with `Retry-After` header for REST requests, and gRPC `ResourceExhausted` status for gRPC requests. ### Configuration Rate limits are configured in the `ratelimits` section of each service: ```yaml services: - name: "myservice" hostregexp: "api.example.com" address: "127.0.0.1:8080" protocol: https ratelimits: # Global rate limit for all endpoints - requests: 100 # Requests allowed per time window per: 1s # Time window duration (1s, 1m, 1h, etc.) burst: 100 # Max burst capacity (defaults to 'requests') # Stricter limit for expensive endpoints - pathregexp: '^/api/v1/expensive.*$' requests: 5 per: 1m burst: 5 ``` This example configures two rate limit rules using a token bucket algorithm. Each client gets a "bucket" of tokens that refills at the `requests/per` rate, up to the `burst` capacity. A request consumes one token; if no tokens are available, the request is rejected. This allows clients to make quick bursts of requests (up to `burst`) while enforcing a steady-state rate limit over time. 1. **Global limit**: All endpoints are limited to 100 requests per second per client, with a burst capacity of 100. 2. **Endpoint-specific limit**: Paths matching `/api/v1/expensive.*` have a stricter limit of 5 requests per minute with a burst of 5. Since both rules are evaluated, requests to expensive endpoints must satisfy both limits. ### Configuration Options | Option | Description | Required | |--------|-------------|----------| | `pathregexp` | Regular expression to match request paths. If omitted, matches all paths. | No | | `requests` | Number of requests allowed per time window. | Yes | | `per` | Time window duration (e.g., `1s`, `1m`, `1h`). | Yes | | `burst` | Maximum burst size. Defaults to `requests` if not set. | No |