Reference · Pay Token

Pay Token spec

The HS256-signed JWT a buyer (or their agent) attaches as Authorization: Bearer when calling a LemonCake gateway URL. Server-side enforcement of budget, call count, expiry, and rate limit happens on every paid request — see Gateway HTTP spec.

Why a Pay Token?

An AI agent that calls a paid API has three bad options: share the operator's API key, hold a long-lived wallet, or tunnel every call through the operator's server. All three are either insecure or operationally expensive.

A Pay Token is the fourth option: the seller mints a narrow, time-bound, spend-capped, rate-limited permission for a specific endpoint. The agent attaches the token. The gateway verifies the signature, looks up server-side state, decrements the budget on success. The agent never sees the seller's upstream credential.

The shape

A Pay Token is a standard JWT. Three encoded parts joined by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJvd24iOiJvX2FiYzEyMyIsImp0aSI6InB0X2RlZjQ1NiIs
InN1YiI6IjQwNjY0YjA2LWFmYjctNGFlMC1hZjFkLWFjZGUxNiIs
ImlhdCI6MTcxNjgwMDAwMCwiZXhwIjoxNzE2ODg2NDAwfQ
.GJoo3p6ZVGWEvMCvtMXBUa1Ov9YdXTQuZykIlHbNWJk

Decoded:

// header
{ "alg": "HS256", "typ": "JWT" }

// payload
{
  "jti": "pt_27788b295d72499fabf5f5d9",   // Pay Token ID (DB primary key)
  "sub": "40664b06-afb7-4ae0-af1d-...",   // endpoint UUID this token can call
  "own": "o_4e48c8bfc7934957",            // owner ID that issued the token
  "iat": 1716800000,                       // issued at (epoch seconds)
  "exp": 1716886400                        // expires at (epoch seconds)
}

// signature
HMAC-SHA256(base64url(header) + "." + base64url(payload), LC_JWT_SECRET)

Claims

ClaimTypeRequiredMeaning
jtistringyesPay Token ID. Doubles as the primary key in lc_pay_tokens — the gateway uses it to look up live state.
substring (UUID)yesEndpoint the token is scoped to. JWT.sub must match the endpoint resolved from the URL's shortId, otherwise 403 token_endpoint_mismatch.
ownstringyesOwner ID (lc_owner cookie) that minted the token. Used for traceability; the gateway doesn't compare it to anything on the request path.
iatnumber (epoch s)yesIssued-at. Set by /api/lc/tokens on POST.
expnumber (epoch s)yesExpiry. After this, the gateway returns 401 token_expired and the row's status flips to expired.

jti, sub, and own are the only application-meaningful claims. iat / exp follow RFC 7519.

Server-side state

Spend cap, call cap, rate limit — the things that make a Pay Token useful — are not in the JWT. They live in Postgres next to the token ID so the seller can revoke, top up, or adjust without re-issuing the JWT to the buyer.

-- lc_pay_tokens
id          text primary key,             -- = JWT.jti
endpoint_id uuid references lc_endpoints, -- = JWT.sub
owner_id    text references lc_owners,    -- = JWT.own
budget      numeric(12, 6),               -- USD spend cap
spent       numeric(12, 6) default 0,     -- decremented on each successful call
max_calls   int,                          -- per-token call cap
calls_used  int default 0,                -- incremented on each successful call
expires_at  timestamptz,                  -- mirrors JWT.exp
status      text   -- 'active' | 'expired' | 'exhausted' | 'revoked'
issued_at   timestamptz default now()
RuleSource of truthWhen checked
JWT signature validLC_JWT_SECRETevery gateway call
JWT not expiredJWT.expevery gateway call
Token status = activelc_pay_tokens.statusevery gateway call
Budget covers this calllc_pay_tokens.spent + endpoint.price_per_call ≤ budgetevery gateway call
Calls remaininglc_pay_tokens.calls_used < max_callsevery gateway call
Rate limit not breachedcount(lc_test_runs WHERE endpoint_id=? AND at >= now-60s) < endpoint.rate_limitevery gateway call

Lifecycle

active
  │
  │  POST /g/<shortId> with Bearer <jwt>
  │  ──── decrement spent / increment calls_used ────►
  │
  ├── exp passed                     ──► status = "expired"  (401 token_expired)
  ├── calls_used >= max_calls        ──► status = "exhausted" (402 token_exhausted)
  ├── DELETE /api/lc/tokens/<jti>    ──► status = "revoked"   (403 token_revoked)
  └── budget exceeded on next call   ──► 402 spend_cap_exceeded (status stays active until exhausted by call cap)

Status is a one-way ratchet — once flipped to expired / exhausted / revoked, the token never comes back. Top-ups happen by issuing a fresh token, not by reactivating an old one.

Issue a Pay Token

Sellers mint tokens via the REST API or via the Pay Token pane on /app.

POST https://www.lemoncake.xyz/api/lc/tokens
Content-Type: application/json
Cookie: lc_owner=<your owner cookie>     // sent automatically from /app

{
  "endpointId":     "40664b06-afb7-4ae0-af1d-...",
  "budget":         5.00,
  "expiresInHours": 24,
  "maxCalls":       100
}
// 201 Created
{
  "token": {
    "id":          "pt_27788b295d72499fabf5f5d9",
    "endpoint_id": "40664b06-afb7-4ae0-af1d-...",
    "budget":      "5.000000",
    "spent":       "0.000000",
    "max_calls":   100,
    "calls_used":  0,
    "expires_at":  "2026-05-29T05:18:42Z",
    "status":      "active",
    ...
  },
  "jwt": "eyJhbGciOiJIUzI1NiI…"      // give this to your buyer
}

The jwt is only returned on issuance — the server never echoes it back later. Hand it to your buyer (Slack DM, email, in-band over your own API), they attach it to gateway calls.

Budget guardrail: budget may not exceed 5× the endpoint's token_budget setting, returning 400 budget_exceeds_endpoint_cap if it does. This keeps a stolen seller cookie from minting an unlimited-spend token.

Revoke a Pay Token

DELETE https://www.lemoncake.xyz/api/lc/tokens/pt_27788b295d72499fabf5f5d9
Cookie: lc_owner=<your owner cookie>

Takes effect on the very next gateway call (no cache). The JWT itself remains cryptographically valid — the gateway rejects it because the DB row says status = revoked. This is intentional: we wanted instant revocation, not waiting for short expiries to roll over.

Why HS256 instead of EIP-712 / Ed25519?

Pay Tokens are issued andverified by the same LemonCake gateway. There's no third party that needs to verify the signature without trusting us. HS256 with a shared secret (LC_JWT_SECRET) is the simplest thing that meets that bar.

When the day comes that a third-party verifier wants to check a Pay Token without calling LemonCake (a Coinbase x402 facilitator wrapping ours, for example), we'll add a second alg (RS256 or Ed25519) under the same jti identity — the JWT format already supports per-token alg negotiation via the header.

Buyer-side usage

The whole interaction from the buyer's side is two HTTP requests their server / agent already knows how to make:

// Node.js
const res = await fetch(`https://www.lemoncake.xyz/g/${shortId}`, {
  method: "POST",
  headers: {
    "Authorization": "Bearer " + PAY_TOKEN_JWT,
    "Content-Type":  "application/json",
  },
  body: JSON.stringify({ query: "…" }),
});

console.log(res.headers.get("x-lemoncake-charge"));     // "0.01"
console.log(res.headers.get("x-lemoncake-upstream-ms")); // "17"

CORS is permissive — works from server, edge function, or browser. See Gateway HTTP spec for the full error table.

What's intentionally NOT in the token

The original v0.1 draft of this spec carried allowed_paths, allowed_methods, price_per_call, rate_limit, metadata, and nonce inside the JWT. We dropped them all. Reasons:

  • Server-side state is more flexible than a signed payload. The seller can adjust price or rate without re-issuing JWTs to every buyer.
  • Replay defence comes from the DB, not a nonce. spent only goes up, so a replayed request that pushed past budget simply fails.
  • Smaller tokens compress better in HTTP headers and survive a wider range of HTTP clients (some have header-size limits).

The current token is intentionally minimal — just enough for a verifier to look up the right row and confirm the signature. Everything operational is in Postgres.

Future

Fields likely to grow into the spec as integrations demand:

  • scope — path / method allowlist enforced at the gateway, for sellers whose origin exposes multiple sensitive operations.
  • aud — facilitator identifier so the same JWT can be honored by a non-LemonCake verifier (x402-compatible mode).
  • Alternative alg — RS256 / Ed25519 for cross-verifier scenarios where shared-secret HS256 isn't acceptable.

Anything we add will follow a single rule: never remove a field. Existing JWTs keep verifying as long as the secret is rotated forward with overlap.

Next