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:

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:

  1. A v1 JSON body (x402Version: 1, accepts array).
  2. A v2 PAYMENT-REQUIRED response header: base64-encoded JSON with x402Version: 2, CAIP-2 network ids, and amount instead of maxAmountRequired.
  3. 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:

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.