coal
coal

Integration Guide

A complete walkthrough — pick what you're selling, see the exact steps, copy working code, and understand what changes when an agent finds your endpoint instead of going through Coal.

If you've already shipped, jump to Direct-endpoint access below — it's the question every integrator asks after the first paid call.


Build it interactively

Step 1 — Pick what you're shipping
1 of 4
API seller (multi-endpoint)

You sell access to an API surface — many routes that share an origin and an auth key. Bundle them under one slug and price each endpoint independently.

Real exampletwitter-aio: 14 endpoints, $0.001–0.008 per call, shared RapidAPI key.

Delivery mode

Coal terminates the request, settles on-chain, forwards to your origin with your encrypted auth header attached.

Step 2 — Wire it up
  1. 1

    Create a bundle

    Console → Paywalls → New Paywall ▾ → Upstream bundle. Name it, set the origin URL, add your encrypted auth header (e.g. x-api-key). Coal holds the key encrypted at rest and injects it on every paid forward.

  2. 2

    Add endpoints (or import OpenAPI)

    Each endpoint is a child paywall: method + path template (e.g. GET /user/{id}) + price. Or paste your OpenAPI spec and Coal bulk-creates one paywall per operation.

  3. 3

    Share the public URL

    Your bundle lives at api.usecoal.xyz/p/{bundle-slug}. Every child endpoint is reachable as api.usecoal.xyz/p/{slug}/{path-template}. List it on your homepage, your RapidAPI page, your docs.

  4. 4

    Get paid in USDC on Base

    Coal pulls payment, splits the fee, sends the rest to your payout wallet — all in the same on-chain transaction. Refunds in one click.

Step 3 — Code
# Create the bundle
curl -X POST https://api.usecoal.xyz/api/console/api-bundles \
  -H "Authorization: Bearer $COAL_TOKEN" \
  -d '{
    "name": "Twitter AIO",
    "slug": "twitter-aio",
    "originUrl": "https://twitter-aio.example.com",
    "originHeaders": { "x-rapidapi-key": "secret-value" }
  }'

# Add an endpoint
curl -X POST https://api.usecoal.xyz/api/console/paywalls \
  -H "Authorization: Bearer $COAL_TOKEN" \
  -d '{
    "bundleId": "bun_xxx",
    "name": "User lookup",
    "method": "GET",
    "pathTemplate": "/user/{id}",
    "price": 0.003,
    "pricingModel": "per_call"
  }'

# That's it. Agents hit api.usecoal.xyz/p/twitter-aio/user/44196397
bash
Step 4 — What an agent sees
RequestAgent calls your URL without payment
GET https://api.usecoal.xyz/p/twitter-aio/user/44196397
1 / 3

How Coal discovery works

When you create a paywall, Coal automatically publishes it to two discovery surfaces:

  1. Catalog APIGET https://api.usecoal.xyz/api/agent/catalog returns a JSON list of every active paywall on the platform: URL, price, asset, chain, and a one-line description. Agents query this directly.
  2. 0G manifest — a signed manifest pinned on 0G Storage. Same payload, but tamper-evident and decentralized. Agents can verify the manifest's hash against the on-chain anchor.

Both contain the same URL the agent will hit to pay. Which URL that is depends on how you integrated:

  • Proxy-mode bundlehttps://api.usecoal.xyz/p/{slug}/{path} (or your own custom domain — both work).
  • SDK-mode paywallyour domain, e.g. https://api.acme.ai/v1/inference.
  • Standalone gatehttps://api.usecoal.xyz/api/paywalls/{id}/verify.

Whichever URL it is, the payout is bound to your wallet at creation, not chosen by the caller — see how Coal knows it's your API and pays your wallet.

Discovery is opt-in per paywall. Set discoverable: false on the paywall to keep it out of the catalog and 0G manifest. Buyers who already have the URL can still pay; new agents won't find it via discovery.


What happens if an agent hits my endpoint directly?

This is the most-asked question after first integration. The honest answer depends on which mode you're in.

SDK mode — your server is the gate

When you wrap your handler with coal-payments, the 402 enforcement runs on your server, before your handler executes:

text
1Agent → https://api.acme.ai/v1/inference (your server)
2
3 coal-payments middleware
4
5 Missing/invalid X-Payment? → 402 with payment requirements
6 Valid signature? → call Coal.verify(), settle on Base, run handler

There's no "direct endpoint to leak". Your domain is the endpoint. Agents find it via Coal's discovery feed, hit it directly, and the middleware on your server handles the handshake. They cannot bypass the gate — there's no other route to your handler.

Proxy mode — Coal fronts your origin

Proxy mode is different. The discoverable URL is Coal's (api.usecoal.xyz/p/...) — or a custom domain you own pointed at the same bundle, since both resolve to it. Coal forwards paid requests to your origin URL (https://your-private-origin.fly.dev). That origin URL isn't a secret in a cryptographic sense — it's just not published.

The risk: if someone finds your origin URL (subdomain enumeration, accidental log, support ticket), they can hit it directly and bypass Coal's 402.

The fix — Coal-Signature HMAC, shipped today. Every proxy-forward from Coal to your origin carries a Stripe-style HMAC header:

text
1Coal-Signature: t=1710000000,v1=abc123…

The signature is HMAC-SHA256 of ${timestamp}.${METHOD}.${path?query}.${body} keyed on a per-bundle forwarderSecret (auto-minted on bundle create, rotatable from the console). Your origin verifies it with coal-payments/origin-guard — one line of middleware — and rejects any request that didn't come through Coal.

ts
1// Express
2import { coalOriginGuard } from 'coal-payments/express';
3app.use(coalOriginGuard({ secret: process.env.COAL_FORWARDER_SECRET! }));
4
5// Next.js App Router
6import { withCoalOriginGuard } from 'coal-payments/next';
7export const POST = withCoalOriginGuard(
8 { secret: process.env.COAL_FORWARDER_SECRET! },
9 async (req) => Response.json({ ok: true }),
10);

Hono and Fastify ship the same middleware. Get your bundle's secret from Console → Paywalls → your upstream bundle → Origin signature secret. The card has a copy-to-clipboard, a tabbed snippet for all four stacks, and a Rotate button.

Properties of this scheme (same as Stripe webhook signatures):

  • Replay protection. The signature includes a Unix timestamp. The verifier enforces ±5-minute tolerance — captured requests can't be replayed beyond the window.
  • Body integrity. The HMAC covers the body, not just the path. POST/PUT/PATCH bodies can't be swapped without invalidating the signature.
  • Method + path integrity. A captured GET signature can't be turned into a POST, and a signature for /public/health can't be replayed against /admin/users.
  • Constant-time compare. Verification uses crypto.timingSafeEqual to avoid leaking signature bytes via timing.
  • One secret per bundle. Compromise of one bundle's secret doesn't affect any other bundle or any other merchant. Rotate from the console at any time.

For higher-security setups: combine the HMAC guard with mTLS / Cloudflare Authenticated Origin Pulls, which drops non-Cloudflare requests at the TLS layer before they ever reach your application. That's the right move if your origin is already behind a CDN — see Cloudflare AOP docs.

If your origin handler already requires auth for a different reason (existing customer API keys, OAuth), proxy mode happily passes through whatever headers you configure in Auth headers on the bundle — you can re-use that auth layer alongside the signature guard.

Verify in any language

coal-payments ships Node middleware (Express, Hono, Fastify, Next.js), but the wire format is intentionally tiny so non-Node merchants can write their own verifier in 10–15 lines. Same scheme Stripe uses for webhook signatures, so any HMAC-SHA256 stdlib will do.

Wire format:

text
1Header: Coal-Signature: t=<unix_seconds>,v1=<hex_sha256>
2Payload: `${t}.${METHOD}.${path?query}.${rawBody}`
3Algo: HMAC-SHA256(payload, forwarderSecret).hex()
4Window: ±300 seconds (reject anything outside)
5Compare: constant-time

Python (Flask):

python
1import hmac, hashlib, time, os
2from flask import request, abort
3
4SECRET = os.environ['COAL_FORWARDER_SECRET'].encode()
5
6@app.before_request
7def verify_coal():
8 header = request.headers.get('Coal-Signature', '')
9 parts = dict(p.split('=', 1) for p in header.split(',') if '=' in p)
10 t, v1 = parts.get('t'), parts.get('v1')
11 if not t or not v1: abort(401)
12 if abs(time.time() - int(t)) > 300: abort(401)
13 payload = f"{t}.{request.method}.{request.full_path.rstrip('?')}.{request.get_data(as_text=True)}".encode()
14 expected = hmac.new(SECRET, payload, hashlib.sha256).hexdigest()
15 if not hmac.compare_digest(v1, expected): abort(401)

Go (net/http):

go
1func coalGuard(next http.Handler) http.Handler {
2 secret := []byte(os.Getenv("COAL_FORWARDER_SECRET"))
3 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
4 parts := map[string]string{}
5 for _, p := range strings.Split(r.Header.Get("Coal-Signature"), ",") {
6 if k, v, ok := strings.Cut(strings.TrimSpace(p), "="); ok { parts[k] = v }
7 }
8 ts, _ := strconv.ParseInt(parts["t"], 10, 64)
9 if math.Abs(float64(time.Now().Unix()-ts)) > 300 { http.Error(w, "expired", 401); return }
10 body, _ := io.ReadAll(r.Body); r.Body = io.NopCloser(bytes.NewReader(body))
11 mac := hmac.New(sha256.New, secret)
12 fmt.Fprintf(mac, "%d.%s.%s.%s", ts, r.Method, r.URL.RequestURI(), body)
13 expected := hex.EncodeToString(mac.Sum(nil))
14 if !hmac.Equal([]byte(parts["v1"]), []byte(expected)) {
15 http.Error(w, "mismatch", 401); return
16 }
17 next.ServeHTTP(w, r)
18 })
19}

PHP (Laravel middleware):

php
1public function handle(Request $request, Closure $next) {
2 $secret = env('COAL_FORWARDER_SECRET');
3 $header = $request->header('Coal-Signature', '');
4 $parts = [];
5 foreach (explode(',', $header) as $p) {
6 [$k, $v] = array_pad(explode('=', trim($p), 2), 2, '');
7 $parts[$k] = $v;
8 }
9 if (empty($parts['t']) || empty($parts['v1'])) abort(401);
10 if (abs(time() - (int)$parts['t']) > 300) abort(401);
11 $payload = $parts['t'] . '.' . $request->method() . '.' . $request->getRequestUri() . '.' . $request->getContent();
12 $expected = hash_hmac('sha256', $payload, $secret);
13 if (!hash_equals($expected, $parts['v1'])) abort(401);
14 return $next($request);
15}

Ruby (Rack middleware):

ruby
1class CoalGuard
2 def initialize(app); @app = app; end
3 def call(env)
4 header = env['HTTP_COAL_SIGNATURE'].to_s
5 parts = header.split(',').map { |p| p.strip.split('=', 2) }.to_h
6 return [401, {}, ['']] unless parts['t'] && parts['v1']
7 return [401, {}, ['']] if (Time.now.to_i - parts['t'].to_i).abs > 300
8 body = env['rack.input'].read; env['rack.input'].rewind
9 path = env['PATH_INFO'] + (env['QUERY_STRING'].empty? ? '' : "?#{env['QUERY_STRING']}")
10 payload = "#{parts['t']}.#{env['REQUEST_METHOD']}.#{path}.#{body}"
11 expected = OpenSSL::HMAC.hexdigest('SHA256', ENV['COAL_FORWARDER_SECRET'], payload)
12 return [401, {}, ['']] unless Rack::Utils.secure_compare(expected, parts['v1'])
13 @app.call(env)
14 end
15end

Same scheme works in Rust, Java, Elixir, .NET — any runtime with HMAC-SHA256 in stdlib. Constant-time compare is essential: == leaks signature bytes via timing.

Common gotchas

  • Trailing newline in your env var. If you pipe the secret via vercel env add or echo without -n, you'll capture a \n and every signature will mismatch. Use printf '%s' or paste directly in the Vercel UI.
  • Body parsers that consume the stream. In Express, coalOriginGuard must run before express.json() and you need express.raw({ type: '*/*' }) to keep the bytes Coal signed. In Fastify, register addContentTypeParser('*', { parseAs: 'buffer' }, ...). The HMAC is over raw bytes, not parsed JSON.
  • Vercel's automatic DDoS mitigation may block scripted clients. Coal's facilitator and your origin both need to accept programmatic traffic. If you host on Vercel, add a project-scope system bypass via the Firewall REST API — agents fail the JS challenge and get blanket 403s otherwise.
  • Path + query are signed exactly as Coal sent them. Don't strip the query string before comparing. The signed pathWithQuery is req.originalUrl in Express, req.url in Fastify, c.req.raw.url's pathname+search in Hono.

Quick comparison

SDK modeProxy mode
Where the 402 is enforcedYour serverCoal's edge
Discoverable URLYour domainapi.usecoal.xyz
Origin URL is(none — your domain is the origin)Stored encrypted in bundle config
Bypass riskNone — your handler is gatedYes if origin URL leaks without shared-secret auth
Setup time~5 lines of middlewareZero code, but configure auth header
When to pick itYou already run the API and want minimal infra changeYou want Coal to terminate everything; you have an upstream key to protect

What an agent's request flow looks like

Whichever mode you're in, the shape an agent sees is identical — only the URL changes:

Discovery
Your AppCoal
GET /api/agent/catalog
CoalYour App
200 OK — [{ url, price, asset, payTo }, ...]
First hit (unpaid)
Your AppCoal
GET <paywall URL>
CoalYour App
402 Payment Required

accepts: [{ scheme, network, maxAmountRequired, asset, payTo }]

Sign + settle
User / BrowserAgent signs EIP-3009 transferWithAuthorization
Your AppCoal
GET <paywall URL> with X-Payment header
CoalBase Chain
transferWithAuthorization (operator pays gas)
Base ChainCoal
Tx mined on Base (~2 sec)
CoalYour App
200 OK + content

X-Payment-Response: { success, transaction, payer }


FAQ

My API is already public. Why would I add Coal? Because "public + free" isn't a business model for agent traffic. Agents will call APIs 10,000× more often than humans. Per-call pricing turns volume into revenue. Coal makes that pricing machine-readable via x402, so agents pay automatically — no human-in-the-loop, no API-key onboarding, no Stripe accounts.

Can I price the same endpoint differently for different callers? Not today as a single paywall. Workaround: create two paywalls at different prices, gate them by a header or path prefix.

Can I cap how much an agent spends per day? Set callQuota on a per-call paywall to cap the number of paid requests per buyer within the accessDuration window. (E.g. "1,000 calls / hour / wallet".)

What chain / token can I receive? USDC on Base mainnet, today. Other tokens and chains are on the roadmap but Base USDC is what every x402 client expects out of the box.

How fast is settle? End-to-end (sign → on-chain → response): roughly 2–3 seconds on Base. The agent's wait time is dominated by Base's block time, not Coal's processing.

Does the buyer need ETH for gas? No. EIP-3009 lets the buyer sign an authorization off-chain; Coal's operator wallet broadcasts the actual transaction and pays the gas. The buyer wallet only needs USDC.

What if I want to handle the on-chain settle myself? That's the SDK-mode persona above. coal-payments middleware verifies the signature, calls Coal to broadcast the settle, and your handler runs after. You never touch the on-chain submission — operator-key custody and gas funding stay with Coal so you don't have to run a hot wallet.