HTTP API
Everything Tollgate does is reachable over plain HTTP. There are four surfaces: the paid content endpoint (/r/...), the x402 facilitator (/facilitator/...), the publisher API (/api/..., Bearer tg_pk_ keys), and the public market index. All machine surfaces speak JSON and authenticate with payment headers or Bearer keys, never cookies.
Examples below use https://<your-app> as the base URL. Local dev defaults to http://localhost:3402. The server advertises whatever BASE_URL is set to in every resource and url field.
Authentication at a glance
| Credential | Format | Used by |
|---|---|---|
| None | 402 offers, /facilitator/*, /market/index.json, browser preview pages |
|
| Payment header | X-PAYMENT (v1) or PAYMENT-SIGNATURE (v2), base64 JSON |
Paying for content on /r/... |
| Publisher key | Authorization: Bearer tg_pk_<32 hex> |
/api/me, /api/agents, /api/resources |
| Agent key | Authorization: Bearer tg_sk_<32 hex> |
/api/agent-status, and inside tollgate-credit payment payloads |
Key formats are strict: the server matches ^Bearer (tg_pk_[0-9a-f]{32})$ and ^Bearer (tg_sk_[0-9a-f]{32})$ exactly. A malformed header is a 401 with missing_or_malformed_publisher_key or missing_or_malformed_agent_key; a well-formed but unknown key is a 401 with unknown_publisher_key or unknown_agent_key.
Paid content: GET /r/:slug/path
The tollgate itself. The route matches ^/r/([a-z0-9-]+)(/.*)?$, so slugs are lowercase alphanumerics and hyphens, and the path after the slug is optional (missing path means /).
Behavior depends on who is asking:
- Browser (
Acceptcontainstext/html, no payment header): 200 with a free HTML preview page. Humans browse free, only machines pay. This happens before the paused check, so previews render even for paused resources. - Machine, no payment header: 402 with the offer (below).
- Machine, valid payment header: 200 with the content plus settlement headers.
- Unknown slug: 404
{"error":"no such resource"}. - Paused resource (machine request): 404
{"error":"resource is paused"}, even with a valid payment. - Unknown path inside a live resource: 404 with
{"error":"no such path in this resource","available":["/today", ...]}. The file is resolved before any settlement, so you are never charged for a miss. A request for/falls back to/index, then/index.md.
To see the 402 from curl, do not send Accept: text/html:
curl -i https://<your-app>/r/eu-rates/today
The 402 offer
Every 402 carries three things at once, so both x402 client generations can pay:
- A v1 JSON body (
x402Version: 1,acceptsarray). - A v2
PAYMENT-REQUIREDresponse header: base64-encoded JSON withx402Version: 2, CAIP-2 network ids, andamountinstead ofmaxAmountRequired. - Human-scannable display headers:
x402-price(for example$0.004),x402-pay-to(shortened address,incr:0x8f3..c21),x402-asset(USDC).
The v1 body, exactly as served (for a resource titled "EU rates daily brief" priced at $0.004 per request; the server joins title and price with U+2014 in description):
{
"x402Version": 1,
"error": "X-PAYMENT or PAYMENT-SIGNATURE header is required",
"accepts": [
{
"scheme": "exact",
"network": "base-sepolia",
"maxAmountRequired": "4000",
"resource": "https://<your-app>/r/eu-rates/today",
"description": "EU rates daily brief \u2014 $0.004 per request",
"mimeType": "application/octet-stream",
"maxTimeoutSeconds": 60,
"payTo": "0x8f3BA7C7a4a4E5CBf4b70FFa1e3b6Cb2dD88bc21",
"asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
"extra": { "name": "USDC", "version": "2" }
},
{
"scheme": "tollgate-credit",
"network": "tollgate",
"maxAmountRequired": "4000",
"resource": "https://<your-app>/r/eu-rates/today",
"description": "EU rates daily brief \u2014 $0.004 per request",
"mimeType": "application/octet-stream",
"maxTimeoutSeconds": 60,
"payTo": "tollgate:publisher:1",
"asset": "USDC-CREDIT",
"extra": { "hint": "Pay with a Tollgate agent API key: payload = { \"apiKey\": \"tg_sk_...\" }" }
}
]
}
Amounts are atomic USDC units: 6 decimals, so "4000" is $0.004. payTo and asset for the exact entry come from the deployment config (PAY_TO_ADDRESS, USDC_ADDRESS, X402_NETWORK; defaults shown are Base Sepolia). The extra on the exact entry is the EIP-712 domain your wallet must sign with; echo it, never hardcode it.
The PAYMENT-REQUIRED header decodes to the same offer in v2 shape:
{
"x402Version": 2,
"error": "X-PAYMENT or PAYMENT-SIGNATURE header is required",
"resource": {
"url": "https://<your-app>/r/eu-rates/today",
"description": "EU rates daily brief \u2014 $0.004 per request",
"mimeType": "application/octet-stream"
},
"accepts": [
{
"scheme": "exact",
"network": "eip155:84532",
"amount": "4000",
"asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
"payTo": "0x8f3BA7C7a4a4E5CBf4b70FFa1e3b6Cb2dD88bc21",
"maxTimeoutSeconds": 60,
"extra": { "name": "USDC", "version": "2" }
},
{
"scheme": "tollgate-credit",
"network": "tollgate:1",
"amount": "4000",
"asset": "USDC-CREDIT",
"payTo": "tollgate:publisher:1",
"maxTimeoutSeconds": 60,
"extra": { "hint": "Pay with a Tollgate agent API key: payload = { \"apiKey\": \"tg_sk_...\" }" }
}
]
}
CAIP-2 mapping: base is eip155:8453, base-sepolia is eip155:84532, tollgate is tollgate:1.
Paying: X-PAYMENT and PAYMENT-SIGNATURE
Retry the same request with one payment header. If both are present, PAYMENT-SIGNATURE (v2) wins. Both are base64-encoded JSON.
v1 X-PAYMENT, custodial scheme:
{
"x402Version": 1,
"scheme": "tollgate-credit",
"network": "tollgate",
"payload": { "apiKey": "tg_sk_0123456789abcdef0123456789abcdef" }
}
v1 X-PAYMENT, on-chain exact scheme (EIP-3009 TransferWithAuthorization, signed off-chain, gasless for the payer):
{
"x402Version": 1,
"scheme": "exact",
"network": "base-sepolia",
"payload": {
"signature": "0x<65-byte hex EIP-712 signature>",
"authorization": {
"from": "0x<payer>",
"to": "0x8f3BA7C7a4a4E5CBf4b70FFa1e3b6Cb2dD88bc21",
"value": "4000",
"validAfter": "1752300000",
"validBefore": "1752300600",
"nonce": "0x<32-byte hex>"
}
}
}
v2 PAYMENT-SIGNATURE wraps the same payloads differently: x402Version: 2, an accepted object echoing the chosen entry from the offer (with its CAIP-2 network), and payload:
{
"x402Version": 2,
"accepted": {
"scheme": "tollgate-credit",
"network": "tollgate:1",
"amount": "4000",
"asset": "USDC-CREDIT",
"payTo": "tollgate:publisher:1",
"maxTimeoutSeconds": 60
},
"payload": { "apiKey": "tg_sk_0123456789abcdef0123456789abcdef" }
}
End to end with curl:
PAYMENT=$(printf '{"x402Version":1,"scheme":"tollgate-credit","network":"tollgate","payload":{"apiKey":"tg_sk_YOURKEY"}}' | base64)
curl -si https://<your-app>/r/eu-rates/today -H "X-PAYMENT: $PAYMENT"
Or let the CLI do the whole dance (fetch offer, sign or attach key, retry):
npx tollgate get https://<your-app>/r/eu-rates/today
npx tollgate get https://<your-app>/r/eu-rates/today --scheme exact # needs TOLLGATE_EVM_KEY
Success response
On settlement the content comes back with status 200, Content-Type taken from the stored file, and two headers:
X-PAYMENT-RESPONSE(if you paid with the v1 header) orPAYMENT-RESPONSE(v2): base64 JSON settlement record.x402-receipt: the receipt id, for exampletg_9f2e4c1a.
The settlement record decodes to:
{
"success": true,
"transaction": "credit:tg_9f2e4c1a",
"network": "tollgate",
"payer": "incr:0x3d1f2a9c4b8e7d6f5a4b3c2d1e0f9a8b7c6d59aa"
}
For exact payments transaction is the real on-chain tx hash and payer the recovered signer address. On the v2 wire, network is the CAIP-2 id (tollgate:1, eip155:84532).
Payment error codes
Any failed payment attempt answers 402 again, with a fresh offer body whose error field carries the code, plus an optional top-level detail string. Codes from the settlement engine:
| Code | Scheme | Meaning |
|---|---|---|
X-PAYMENT or PAYMENT-SIGNATURE header is required |
No payment header on a machine request | |
malformed X-PAYMENT header / malformed PAYMENT-SIGNATURE header |
Header did not decode to a valid v1/v2 payload | |
unsupported_scheme_or_network |
Scheme or network not in the offer | |
missing_api_key |
tollgate-credit | Payload lacks an apiKey string |
unknown_api_key |
tollgate-credit | No agent with that key |
domain_not_allowed |
tollgate-credit | Resource domain (<slug>.tollgate.site) not in the agent's allowlist; detail names the domain. Empty allowlist allows everything; *. wildcards supported |
payment_requires_approval |
tollgate-credit | Price is above the agent's ask-above threshold; a pending_approval ledger row is created (deduplicated per agent and resource) and the owner is notified. Retry after approval; one approval releases exactly one purchase |
over_max_per_request |
tollgate-credit | Price exceeds the agent's per-request cap. Not applied when an explicit approval is being consumed |
daily_budget_exceeded |
tollgate-credit | Paid total since midnight (server time) plus this price exceeds the daily budget. Hard stop, applies even to approved purchases |
insufficient_funds |
tollgate-credit | Agent balance below the price (checked atomically inside the debit transaction) |
settlement_error |
tollgate-credit | Transaction failure; detail has the error |
no_exact_requirements |
exact | Internal: offer had no exact entry |
missing_authorization, invalid_from_address, invalid_to_address, invalid_payload, invalid_network, invalid_payment_requirements |
exact | Structural validation failures, rejected locally |
invalid_exact_evm_payload_recipient_mismatch |
exact | authorization.to does not equal the offer's payTo |
invalid_exact_evm_payload_signature |
exact | Signature malformed, or the recovered EIP-712 signer does not match authorization.from |
invalid_exact_evm_payload_authorization_value |
exact | value below the required amount |
invalid_exact_evm_payload_authorization_valid_after / _valid_before |
exact | Time window not open yet, or expired |
facilitator_unreachable |
exact | Upstream facilitator down; Tollgate fails closed |
facilitator_rejected, verification_failed, settlement_failed |
exact | Remote verify or settle said no (insufficient on-chain funds, used nonce, and so on) |
Custodial rules are checked in this order: domain allowlist, ask-above, max per request, daily budget, balance. Every attempt, including blocked and pending ones, lands in the payments ledger.
Facilitator endpoints
Tollgate exposes the standard x402 facilitator surface at /facilitator, backed by the same wallet provider the content route uses. External resource servers can point at it. JSON bodies are limited to 64 KB. These endpoints are not rate limited.
GET /facilitator/supported
curl -s https://<your-app>/facilitator/supported
{
"kinds": [
{ "x402Version": 1, "scheme": "exact", "network": "base-sepolia" },
{ "x402Version": 2, "scheme": "exact", "network": "eip155:84532" },
{ "x402Version": 1, "scheme": "tollgate-credit", "network": "tollgate" },
{ "x402Version": 2, "scheme": "tollgate-credit", "network": "tollgate:1" }
],
"extensions": [],
"signers": {},
"provider": "facilitator:x402.org"
}
POST /facilitator/verify
Body: { "x402Version": 1, "paymentPayload": { ... }, "paymentRequirements": { ... } }. The payload may be v1 (top-level scheme/network) or v2 (x402Version: 2 with an accepted echo); both are normalized. Only the exact scheme on the configured network is verifiable here; anything else answers 200 with {"isValid":false,"invalidReason":"unsupported_scheme_or_network"}. A body missing either field is a 400 with malformed_request.
Response: { "isValid": true, "payer": "0x..." } or { "isValid": false, "invalidReason": "<code>" }. Verify never moves funds.
POST /facilitator/settle
Same request shape. Response:
{ "success": true, "transaction": "0x<tx hash>", "network": "base-sepolia", "payer": "0x<from>" }
On failure: { "success": false, "errorReason": "<code>", "transaction": "", "network": "...", "payer": "..." }. Settlement is idempotent per authorization nonce and fails closed if the upstream facilitator is unreachable.
Publisher API
Bearer-authenticated with your publisher key (tg_pk_..., shown on the dashboard). This is the API the tollgate CLI talks to. Request bodies are JSON, limited to 4 MB.
GET /api/me
curl -s https://<your-app>/api/me -H "Authorization: Bearer tg_pk_YOURKEY"
{
"email": "[email protected]",
"displayName": "You",
"balance": "$24.60",
"balanceAtomic": "24600000",
"resources": [
{
"slug": "eu-rates",
"title": "EU rates daily brief",
"kind": "feed",
"price": "$0.004",
"unit": "request",
"status": "live",
"url": "https://<your-app>/r/eu-rates",
"paths": ["/today"]
}
]
}
balance is a display string; balanceAtomic is the exact integer in micro-USDC.
GET /api/agents
Lists the caller's agents:
{
"agents": [
{
"id": 1,
"name": "briefing-bot",
"apiKey": "tg_sk_0123456789abcdef0123456789abcdef",
"address": "incr:0x3d1f2a9c4b8e7d6f5a4b3c2d1e0f9a8b7c6d59aa",
"balance": "$24.60",
"schedule": "daily 05:00"
}
]
}
POST /api/resources
Create a resource, or update it if the slug already exists and belongs to you. Create answers 201, update answers 200 with "updated": true.
curl -s https://<your-app>/api/resources \
-H "Authorization: Bearer tg_pk_YOURKEY" \
-H "Content-Type: application/json" \
-d '{
"slug": "eu-rates",
"title": "EU rates daily brief",
"description": "ECB moves, spreads, and a 200-word summary.",
"kind": "feed",
"price": "0.004",
"unit": "request",
"files": {
"/today": { "content": "# EU rates\n...", "contentType": "text/markdown; charset=utf-8" }
}
}'
Validation rules, exactly as enforced:
| Field | Rule | Error on violation |
|---|---|---|
title |
Required, trimmed, non-empty | 400 title_required |
price |
"0.004" or "$0.004", digits with up to 6 decimals |
400 invalid_price |
price |
Minimum $0.001 (1000 micro) | 400 price_below_minimum, minimum: "0.001" |
kind |
One of feed, folder, dataset, site, tool; anything else silently becomes folder |
|
unit |
One of request, document, copy, query; anything else becomes request |
|
files |
Required, at least one entry | 400 files_required |
files |
At most 200 entries | 400 too_many_files, max: 200 |
files |
Total content at most 2 MiB (2097152 bytes) | 400 payload_too_large, maxBytes: 2097152 |
| file path | Must match ^/[\w./-]{0,200}$ and contain no .. |
400 invalid_path |
file content |
Must be a string | 400 invalid_file_content |
file contentType |
Optional; truncated to 100 chars; guessed from extension when absent (.md, .json, .html, .csv, .txt, else application/octet-stream) |
|
slug |
Slugified from slug or title: lowercase, non-alphanumerics collapsed to -, max 40 chars |
|
slug |
Must not belong to another publisher | 409 slug_taken |
description |
Optional, trimmed, truncated to 300 chars |
Response:
{
"ok": true,
"slug": "eu-rates",
"url": "https://<your-app>/r/eu-rates",
"price": "$0.004",
"unit": "request",
"paths": ["/today"],
"updated": false
}
Agent status: GET /api/agent-status
Authenticated with an agent key (tg_sk_...) instead of a publisher key. This is what the MCP server polls to show a wallet's state.
curl -s https://<your-app>/api/agent-status -H "Authorization: Bearer tg_sk_YOURKEY"
{
"name": "briefing-bot",
"address": "incr:0x3d1f2a9c4b8e7d6f5a4b3c2d1e0f9a8b7c6d59aa",
"balance": "$24.60",
"spentToday": "$0.012",
"dailyBudget": "$5",
"maxPerRequest": "$0.05",
"askAbove": "$0.25",
"allowedDomains": ["*.tollgate.site", "blog.mvos.site"],
"schedule": "daily 05:00"
}
spentToday sums paid payments since midnight server time, the same window the daily-budget rule uses.
Market index: GET /market/index.json
Machine-readable catalog of every live resource, no auth. Ordered by distinct paying buyers this month, then paid request volume this month, then recency, the same honest ranking the marketplace page uses.
curl -s https://<your-app>/market/index.json
{
"x402": {
"versions": [1, 2],
"discovery": "every resource answers 402 with payment requirements"
},
"resources": [
{
"title": "EU rates daily brief",
"description": "ECB moves, spreads, and a 200-word summary.",
"url": "https://<your-app>/r/eu-rates",
"kind": "feed",
"price": "$0.004",
"priceAtomic": "4000",
"unit": "request",
"publisher": "Marco Vos",
"paths": ["/today"],
"accepts": ["exact", "tollgate-credit"]
}
]
}
No pricing negotiation is needed for discovery: hit any url without a payment header and the 402 body is the full, current offer.
Rate limits
Token buckets, per client IP, in memory per process. Capacity is the burst allowance; refill is the sustained rate.
| Scope | Burst capacity | Refill |
|---|---|---|
/r/* (paid content) |
240 requests | 240 per minute |
/api/* (publisher and agent API) |
120 requests | 120 per minute |
/login, /signup |
15 requests | 10 per minute |
Over the limit you get:
{ "error": "rate_limited", "retryAfterSeconds": 60 }
with status 429. /facilitator/* and /market/index.json are not behind a limiter.
CSRF on form endpoints
Browser form POSTs (/login, /signup, /logout, dashboard and agent management forms) are protected with a double-submit token: the tg_csrf cookie, issued on any HTML GET, must match a _csrf field in the form body or an x-csrf-token header. Enforcement applies only to application/x-www-form-urlencoded and multipart/form-data POSTs; a mismatch is a 403 HTML page.
None of this touches the machine surfaces documented above. Payments, the facilitator, and the Bearer-key API carry no session cookie and send JSON, so CSRF never applies to them. If you script the API with curl, you never need a CSRF token.