# HTTP 402 Payment Gate

Charge AI agents (or any HTTP client) for API access with the `402 Payment Required` status code. The gate is **SDK-only -- no new backend routes**: it speaks to the existing merchant endpoints (`POST /v1/payment_sessions` to mint a challenge, `GET /v1/payment_sessions/:id` to verify payment) with your `sk_` key, and challenges unpaid requests with an x402-inspired JSON body carrying a fresh checkout session. The agent pays that session through the normal hosted/embedded checkout -- any token, any chain -- then retries with an `X-PAYMENT` header.

Runtime-neutral by construction: Web Crypto only, global `fetch`, zero `node:`/React imports -- runs on Node 18+, Bun, Deno, and edge runtimes.

## Install

```bash
npm install @peltier/node
```

Everything lives in the server-only, React-free backend package:

```typescript
import { createPaymentGate, peltier402Express, peltier402Fetch, parse402 } from '@peltier/node'
```

## The Flow

1. **Unpaid request** (no `X-PAYMENT` header) → the gate mints a checkout session priced at `price` and answers `402` with the challenge body below.
2. **The agent pays the session** -- normal checkout: `parse402` hands it the `session_id` + `client_secret` (+ `checkout_url` when configured), and the payment settles on-chain like any other session.
3. **Retry with `X-PAYMENT: <session_id>`** → the gate verifies the session server-side with the `sk_` key (never trusting the agent's claim). Outcome `succeeded` or `overpaid` → the session is consumed (**single grant** -- a paid session cannot be replayed into a second token) and the gate issues a `pt_` access token in the `X-PAYMENT-TOKEN` response header.
4. **Subsequent requests send `X-PAYMENT: pt_…`** (the composite `<session_id>.<pt_…>` also parses), which verifies **offline** -- HMAC + expiry, no store, no API hit.

If the payment is still settling (status `registering` / `pending` / `detected` / `sweeping` / `bridging` -- or `swept` before the outcome is classified -- no `payment_outcome` yet), the gate re-challenges with the **same** session so the agent keeps waiting instead of paying twice -- provided this gate instance minted it (`GET /v1/payment_sessions/:id` never returns the `client_secret`, so foreign sessions degrade to a fresh challenge). `failed` / `underpaid` / `expired` / unknown → fresh session.

## createPaymentGate

```typescript
const gate = createPaymentGate({
  apiUrl: 'https://api.peltier.xyz',
  secretKey: process.env.PELTIER_SK!,  // sk_ — the gate runs SERVER-SIDE only
  price: '0.10',                       // USDC decimal string per grant
})
// gate.handle({ paymentHeader, resourceUrl }) → GateResult
```

| Option | Default | Description |
| --- | --- | --- |
| `apiUrl` | required | Peltier API base URL. |
| `secretKey` | required | Merchant `sk_` key. |
| `price` | required | Price per grant, USDC decimal string (e.g. `"0.10"`) -- at most 6 decimal places, greater than zero. Validated once at gate creation (an invalid price throws immediately); sent to the backend as USDC base units, echoed verbatim as `maxAmountRequired`. |
| `description` | `''` | Echoed in the 402 body. |
| `resource` | request URL | Overrides the per-request `resource` in the 402 body, and is bound into the `pt_` token scope (below). |
| `publishableKey` | -- | Surfaced as `extra.publishable_key` so agents can drive the embedded checkout. |
| `checkoutBaseUrl` | -- | When set, `extra.checkout_url = <checkoutBaseUrl>/pay/<session_id>` -- your checkout page URL (a page you host that drives the checkout for the session). |
| `tokenTtlSeconds` | `86400` | Access-token lifetime (24h). |
| `grantStore` | in-memory `Set` | Single-grant enforcement hook (below). |

`gate.handle` returns `{ granted: true, token, sessionId }` or `{ granted: false, status: 402, body, headers }` -- use it directly in any framework the two adapters don't cover.

## Adapters

Express / Connect -- unpaid → 402 JSON challenge; paid → sets `X-PAYMENT-TOKEN`, attaches `req.peltierPayment = { sessionId, token }`, calls `next()`:

```typescript
import { peltier402Express } from '@peltier/node'

app.use('/paid', peltier402Express({ apiUrl, secretKey, price: '0.10' }))
```

Fetch-API handlers (Next.js route handlers, Hono, `Bun.serve`) -- extra handler arguments (e.g. Next's route context) pass through untouched:

```typescript
import { peltier402Fetch } from '@peltier/node'

export const GET = peltier402Fetch({ apiUrl, secretKey, price: '0.10' })(
  async (request) => Response.json({ report: '…' }),
)
```

## The 402 Body

x402-shaped (`x402Version: 1`), scheme `peltier-checkout`:

```json
{
  "x402Version": 1,
  "error": "payment_required",
  "accepts": [
    {
      "scheme": "peltier-checkout",
      "network": "multi-chain",
      "maxAmountRequired": "0.10",
      "resource": "https://api.acme.com/paid/report",
      "description": "",
      "mimeType": "application/json",
      "maxTimeoutSeconds": 1800,
      "extra": {
        "session_id": "a1b2c3d4-…",
        "client_secret": "cs_…",
        "publishable_key": "pk_…",
        "api_url": "https://api.peltier.xyz",
        "checkout_url": "https://checkout.acme.com/pay/a1b2c3d4-…",
        "expires_at": 1751719800
      }
    }
  ]
}
```

`publishable_key` and `checkout_url` appear only when the corresponding options are set.

## Access Tokens (`pt_`)

Stateless HMAC bearer tokens:

```
pt_<sessionId>.<exp>.<hex HMAC-SHA256(secret, "<sessionId>.<exp>")>
secret = HMAC-SHA256(secretKey, "peltier_402_token|<resource>|<price>")   // raw bytes, derived once
```

The key label binds the gate's **static scope**: the `resource` option (empty string when unset -- never the per-request URL) and the configured `price`, verbatim. A token minted at a `$0.01` gate never verifies at a `$100` gate under the same `sk_`; gates with identical `secretKey` + `resource` + `price` accept each other's tokens (the scope is config, not instance).

The token key is derived from the `sk_` but never equal to it -- a leaked token key cannot mint API calls. Verification is offline (shape, expiry, timing-safe HMAC compare); an expired or forged token degrades to a fresh 402 challenge, never an error.

## GrantStore

One paid session buys **one** token issuance. The default store is a per-process in-memory `Set` with an atomic `addIfAbsent` -- fine for a single instance, but a multi-process deployment could grant one token per process from the same session. Hand in a shared implementation (methods may be sync or async):

```typescript
const gate = createPaymentGate({
  apiUrl, secretKey, price: '0.10',
  grantStore: {
    has: (id) => redis.sismember('peltier:grants', id).then(Boolean),
    add: async (id) => { await redis.sadd('peltier:grants', id) },
    // Optional atomic check-and-add: true iff newly recorded (SADD returns 1/0).
    addIfAbsent: (id) => redis.sadd('peltier:grants', id).then((n) => n === 1),
  },
})
```

When `addIfAbsent` is present the gate calls it **instead of** `has()` + `add()`, so two concurrent proofs of the same session grant exactly once. Stores implementing only `has`/`add` keep working, minus that atomicity.

## Agent Side: parse402

Extracts the peltier-checkout offer from any 402 response body. Returns `null` when the body is not a Peltier x402 challenge -- agents probe arbitrary 402s, so it never throws:

```typescript
import { parse402 } from '@peltier/node'

const res = await fetch(url)
if (res.status === 402) {
  const offer = parse402(await res.json())
  if (offer) {
    // offer: { sessionId, clientSecret, apiUrl, publishableKey?, checkoutUrl?, expiresAt, amount }
    // pay the session (hosted page at offer.checkoutUrl, or drive the embedded checkout),
    // then retry with:  X-PAYMENT: <offer.sessionId>
  }
}
```

## Limits

- **Settlement is a real checkout session**, not an instant on-chain transfer: the grant lands when the deposit is detected, swept, and classified (`payment_outcome` = `succeeded`/`overpaid`) -- seconds to minutes depending on chain and route, within the session's 30-minute TTL. Price the token TTL accordingly rather than gating per-request payments on settlement latency.
- **Tokens are bound to `price` + `resource`, not to the request URL**: omitting the `resource` option shares tokens across every route served by the same gate config. Partition access by giving routes distinct gate configs (different `resource` or `price`).
- **Every unauthenticated request mints a backend checkout session.** The gate bounds its own memory (expired in-flight sessions are swept; the cache is capped at 10,000 entries, oldest evicted first), but nothing bounds the backend sessions a flood creates -- put upstream rate limiting in front of gated routes.
- **Verification is server-side, per grant**: the middleware calls `GET /v1/payment_sessions/:id` with the `sk_` once per session-id proof. Token verification afterwards is free and offline.
- An `underpaid` session never grants -- there is no partial access; the agent gets a fresh challenge and pays again.
- The gate meters *access* (a time-boxed bearer token), not *usage* -- per-call quotas on top of a valid token are your application's concern.
