Paywalls (x402 Protocol)
⚡ Try it live →
Paywalls let you gate any API endpoint or piece of content behind a micropayment. A request either carries proof of payment and receives the resource, or it is rejected with an HTTP 402 Payment Required and instructions on how to pay.
This pattern is defined by the x402 protocol — an open standard that assigns real semantics to the long-dormant HTTP 402 status code. Coal acts as the facilitator: the client signs an EIP-3009 transferWithAuthorization, Coal validates and submits it on-chain via an operator wallet. The client never needs ETH for gas.
Paywalls are ideal for pay-per-call APIs, premium content downloads, AI agent tool access, and any flow where charging a subscription is overkill.
How x402 Works
{ x402Version, accepts: [{ scheme, network, maxAmountRequired, asset, payTo, ... }] }
X-PAYMENT: base64({ scheme, network, payload: { signature, authorization } })
X-PAYMENT-RESPONSE: base64({ success, transaction, payer })
- Client requests the resource (or
GET /api/paywalls/:id/verify?address=...) - Server replies 402 — body is the standard x402 envelope
{ x402Version: 1, accepts: [...] }listing scheme, network (eip155:8453for Base), exact amount in base units, settlement asset (USDC), andpayTo - Client signs an EIP-3009 authorization — no on-chain transaction yet, just an off-chain signature
- Client POSTs the signature to the verify URL with
X-PAYMENT: base64(JSON) - Coal settles on-chain — submits
transferWithAuthorizationvia the operator wallet, returns 200 +X-PAYMENT-RESPONSEcontaining the tx hash
Creating a Paywall
Via the Console (Phase 3 UI)
- Open the Developer Console.
- Navigate to Paywalls → Create Paywall.
- Set the name, price, content type, and pricing model.
- Copy the generated paywall ID.
Via the API
POST /api/console/paywalls
1curl -X POST https://api.usecoal.xyz/api/console/paywalls \2 -H "Content-Type: application/json" \3 -H "Authorization: Bearer <privy_access_token>" \4 -d '{5 "name": "Premium API Access",6 "price": 1.00,7 "currency": "USDC",8 "contentType": "api",9 "pricingModel": "per_call"10 }'
Body Parameters
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Human-readable label shown on the payment prompt |
price | number | Yes | Amount required to unlock this paywall |
currency | string | No | Settlement currency. Defaults to USDC |
contentType | string | Yes | api | content | download |
pricingModel | string | Yes | one_time | per_call |
Response
1{2 "id": "pw_clx9abc123def456",3 "name": "Premium API Access",4 "price": "1.00",5 "currency": "USDC",6 "contentType": "api",7 "pricingModel": "per_call",8 "createdAt": "2026-03-22T12:00:00.000Z"9}
The x402 Wire Format
Step 1 — GET /api/paywalls/:id/verify?address=0x... → 402
1{2 "x402Version": 1,3 "accepts": [4 {5 "scheme": "exact",6 "network": "eip155:8453",7 "maxAmountRequired": "1000000",8 "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",9 "payTo": "0xMerchantPayoutAddress...",10 "resource": "https://api.usecoal.xyz/api/paywalls/pw_clx9.../verify",11 "description": "Premium API Access",12 "mimeType": "application/json",13 "maxTimeoutSeconds": 60,14 "extra": { "name": "USD Coin", "version": "2" }15 }16 ]17}
maxAmountRequired is denominated in base units (USDC has 6 decimals, so 1000000 = $1.00).
Step 2 — Sign EIP-3009 with the payer's wallet
1const authorization = {2 from: payerAddress,3 to: payTo,4 value: maxAmountRequired, // base units, as string5 validAfter: '0',6 validBefore: String(Math.floor(Date.now() / 1000) + 300), // 5 min7 nonce: '0x' + crypto.randomBytes(32).toString('hex'),8};910const signature = await wallet.signTypedData(11 { name: 'USD Coin', version: '2', chainId: 8453, verifyingContract: USDC_ADDRESS },12 { TransferWithAuthorization: [13 { name: 'from', type: 'address' },14 { name: 'to', type: 'address' },15 { name: 'value', type: 'uint256' },16 { name: 'validAfter', type: 'uint256' },17 { name: 'validBefore', type: 'uint256' },18 { name: 'nonce', type: 'bytes32' },19 ]20 },21 authorization,22);
Step 3 — POST /api/paywalls/:id/verify with X-PAYMENT
1const paymentPayload = {2 x402Version: 1,3 scheme: 'exact',4 network: 'eip155:8453',5 payload: { signature, authorization },6};78const xPayment = Buffer.from(JSON.stringify(paymentPayload)).toString('base64');910const res = await fetch(verifyUrl, {11 method: 'POST',12 headers: { 'X-PAYMENT': xPayment, 'content-type': 'application/json' },13 body: '{}',14});
Server replies 200 with X-PAYMENT-RESPONSE: base64(JSON):
1{2 "success": true,3 "transaction": "0xTxHashOnBase...",4 "network": "eip155:8453",5 "payer": "0xPayerAddress..."6}
The endpoint is idempotent: a second POST with a different valid signature for the same paywall+payer short-circuits to 200 without re-charging.
On settlement failure (insufficient amount, expired auth, nonce already used, wrong recipient) the server replies 402 again with the same accepts array plus an X-PAYMENT-RESPONSE containing errorReason.
Agent Integration
For AI agents, the Coal Agent SDK bundles all of the above into a single tool call. See the agent-payments docs for the pay_x402_paywall example.
Pricing Models
| Model | Behaviour | Best for |
|---|---|---|
one_time | A single payment unlocks access permanently for that address | Content downloads, lifetime API keys |
per_call | Each API call requires a fresh payment (or valid cached access token) | Metered APIs, AI tool calls, pay-per-use data |
Content Types
| Value | Intended use |
|---|---|
api | REST or GraphQL endpoints — returns an access token |
content | Articles, videos, PDFs — returns a signed download URL or HTML body |
download | Binary file downloads — returns a short-lived presigned URL |
