bolthub logobolthub
Guides

Using Paywalled APIs

How to consume API endpoints behind a bolthub L402 paywall from HTTP clients and AI agents.

Non-custodial architecture

bolthub is fully non-custodial. When an AI agent or client pays a Lightning invoice for API access, the payment goes directly to the API provider's wallet. The gateway issues standard bolt11 invoices, so consumers can pay with any Lightning wallet: LND, Phoenix, Alby, a mobile wallet, WebLN, or any bolt11-compatible client. bolthub never holds, receives, or controls user funds.

API providers pay a small usage-based platform fee, billed monthly via Lightning, with 7 days to pay each invoice:

ComponentCost (per month)
Base fee5,000 sats
First 500 requestsFree (included in base)
501 – 50,0002 sats/request
50,001 – 500,0001 sat/request
500,001+0.5 sats/request

The usage fee is capped at 5% of what your endpoints earned that cycle, measured from settled invoices. If the per-request schedule would exceed the cap, you pay the cap instead, so micro-priced endpoints stay profitable at any volume. The cap only ever lowers a bill; the base fee is unchanged.

Note: Metering is per-month (30-day rolling cycle). Only served responses count: paywall challenges, health probes, and failed requests are never billed. Auto-pay via NWC settles invoices automatically; manual payment is supported with a 7-day grace window before the gateway pauses. Cycles with zero traffic are free: no invoice is generated.

Every workspace includes a 1-month free trial with no platform fees. The trial clock starts when the workspace publishes its first endpoint. An empty workspace stays free indefinitely. To stop the platform fee entirely, delete the workspace (Settings → Manage Workspaces); that also cancels any outstanding invoice.

How it works

bolthub uses the L402 protocol to gate API access behind Lightning micropayments. When you hit a paywalled endpoint without paying, you get back a 402 Payment Required response containing a Lightning invoice. You pay the invoice, receive a preimage, and retry the request with proof of payment.

Client                          Gateway                         Origin API
  │                               │                                │
  │  GET /v1/weather              │                                │
  │──────────────────────────────▶│                                │
  │                               │                                │
  │  402 Payment Required         │                                │
  │  WWW-Authenticate: L402 ...   │                                │
  │◀──────────────────────────────│                                │
  │                               │                                │
  │  (pay Lightning invoice)      │                                │
  │                               │                                │
  │  GET /v1/weather              │                                │
  │  Authorization: L402 ...      │                                │
  │──────────────────────────────▶│  GET https://origin.com/weather│
  │                               │───────────────────────────────▶│
  │                               │                                │
  │         200 OK + data         │          200 OK + data         │
  │◀──────────────────────────────│◀───────────────────────────────│

Every paywalled endpoint is accessible at:

https://{slug}.gw.bolthub.ai{path}

Where {slug} is the tenant's subdomain and {path} is the endpoint path (e.g. /v1/weather).

Step-by-step: calling a paywalled endpoint

1. Make the initial request

Send a normal HTTP request to the gateway URL. No authentication is needed for this first call.

curl -i https://acme.gw.bolthub.ai/v1/weather?city=berlin

2. Receive the 402 challenge

The gateway responds with 402 Payment Required and a WWW-Authenticate header containing two values:

FieldDescription
macaroonBase64-encoded macaroon (access token bound to this payment)
invoiceBolt11 Lightning invoice to pay
HTTP/1.1 402 Payment Required
WWW-Authenticate: L402 macaroon="AGIAJEemVQUTEyNCR0exk7ek90Cg==", invoice="lnbc1500n1pj9..."
Content-Type: application/json

{"error": "Payment Required", "paymentRequest": "lnbc1500n1pj9...", "amountSats": 10, "paymentHash": "abc123..."}

3. Pay the Lightning invoice

Pay the bolt11 invoice using any Lightning wallet or programmatic Lightning client. After payment settles, you receive a preimage (a 32-byte hex string).

Using a CLI wallet (e.g. lncli):

lncli payinvoice lnbc1500n1pj9...
# Returns preimage: 1234abcd5678ef901234abcd5678ef901234abcd5678ef901234abcd5678ef90

Using a web wallet:

Copy the invoice string and paste it into your wallet's "Send" field. The wallet will show you the preimage after payment succeeds.

4. Retry with the L402 token

Combine the macaroon and preimage into an Authorization header and resend your original request:

curl -i \
  -H 'Authorization: L402 AGIAJEemVQUTEyNCR0exk7ek90Cg==:1234abcd5678ef901234abcd5678ef901234abcd5678ef90' \
  https://acme.gw.bolthub.ai/v1/weather?city=berlin

The format is:

Authorization: L402 {macaroon_base64}:{preimage_hex}

5. Receive the response

If the preimage is valid, the gateway proxies your request to the origin API and returns the response:

HTTP/1.1 200 OK
Content-Type: application/json

{"city": "berlin", "temp_c": 18, "condition": "partly cloudy"}

Pricing models

The API provider chooses one of five pricing models per endpoint:

ModelBehaviorExample
per_requestFixed price in sats for each request10 sats per call
per_kbDeposit-based session; each response deducts sats proportional to its size in KB100 sats deposit, 2 sats per KB
token_bucketPurchase a bucket of tokens; each request consumes one100 sats for 50 requests
time_passPay once for time-limited unlimited access500 sats for 60 minutes
meteredPrepay a balance, deducted per use at a per-request unit cost1000 sats prepaid, 5 sats per call

Endpoints without a pricing rule are free (no payment required). The invoice amount in the 402 response reflects the configured price.

402 response body

The 402 response body includes the invoice details. Session-based models (time_pass, metered, token_bucket, per_kb) also include a model field and model-specific parameters:

{
  "error": "Payment Required",
  "paymentRequest": "lnbc...",
  "amountSats": 10,
  "paymentHash": "abc123..."
}

Session model-specific fields:

ModelAdditional fields
time_passdurationMinutes - session duration in minutes
meteredunitCostSats - cost per request deducted from balance
per_kbunitCostSats - cost per KB deducted from deposit
token_buckettokenBudget - number of requests included

Free try

Some endpoints have free try enabled, allowing authenticated users to make one free request per endpoint per day without payment. Each endpoint has its own daily quota. To use it, include a Bearer token in the Authorization header:

curl -H 'Authorization: Bearer {supabase_jwt}' \
  https://acme.gw.bolthub.ai/v1/weather?city=berlin

If the free try is available, the gateway proxies the request and returns an X-Free-Try: used header. If already used today, the normal L402 payment flow applies.

Time pass flow

With time_pass, the 402 response includes the access duration. After payment and verification, the gateway returns a session token in the X-Session-Token response header. Include this token in subsequent requests; all requests within the time window are proxied without additional payment.

1. GET /v1/data → 402 (includes durationMinutes in response body)
2. Pay invoice → get preimage
3. GET /v1/data with Authorization: L402 {mac}:{preimage}
   → 200 + X-Session-Token: {session_token} + X-Session-Expires: 2027-01-01T01:00:00Z
4. GET /v1/data with X-Session-Token: {session_token}
   → 200 (no payment needed until expiry)

Metered / prepaid balance flow

With metered, you prepay a balance (e.g. 1000 sats) and each request deducts a fixed unit cost (e.g. 5 sats). When the balance is depleted, you receive a new 402 challenge.

1. GET /v1/compute → 402 (includes unitCostSats in response body)
2. Pay invoice → get preimage
3. GET /v1/compute with Authorization: L402 {mac}:{preimage}
   → 200 + X-Session-Token: {token} + X-Session-Balance: 995
4. GET /v1/compute with X-Session-Token: {token}
   → 200 + X-Session-Balance: 990
5. ... (repeat until balance depleted)
6. GET /v1/compute with X-Session-Token: {token}
   → 402 (session depleted, new invoice)

Token bucket flow

With token_bucket, you purchase a fixed number of requests (e.g. 50 requests for 100 sats). Each request consumes one token. The response includes X-Session-Balance showing remaining tokens.

1. GET /v1/data → 402 (includes tokenBudget in response body)
2. Pay invoice → get preimage
3. GET /v1/data with Authorization: L402 {mac}:{preimage}
   → 200 + X-Session-Token: {token} + X-Session-Balance: 49
4. GET /v1/data with X-Session-Token: {token}
   → 200 + X-Session-Balance: 48
5. ... (repeat until tokens depleted)
6. GET /v1/data with X-Session-Token: {token}
   → 402 (bucket depleted, new invoice)

Per-KB (data transfer) flow

With per_kb, you pay a deposit that creates a session. Each response deducts sats proportional to its size in kilobytes (unitCostSats per KB). The response includes X-Data-Size-KB and X-Data-Cost-Sats headers showing the deduction.

1. GET /v1/large-data → 402 (includes unitCostSats in response body)
2. Pay invoice (deposit) → get preimage
3. GET /v1/large-data with Authorization: L402 {mac}:{preimage}
   → 200 + X-Session-Token: {token} + X-Data-Size-KB: 15 + X-Data-Cost-Sats: 30 + X-Session-Balance: 70
4. GET /v1/large-data with X-Session-Token: {token}
   → 200 + X-Data-Size-KB: 8 + X-Data-Cost-Sats: 16 + X-Session-Balance: 54
5. ... (repeat until balance depleted)

Response caching

API providers can enable response caching per endpoint. When caching is active:

  • Only GET responses with 2xx status codes are cached
  • Cached responses include an X-Cache: HIT header and X-Cache-Age header (seconds since cached)
  • Cache misses include X-Cache: MISS
  • Cache TTL is configured by the provider

Using lnget (Lightning Agent Tools)

lnget is a command-line HTTP client from Lightning Labs that handles L402 payments automatically. It works with any bolthub gateway out of the box, with no SDK or MCP setup required.

# Install lnget (requires Go 1.24+)
git clone https://github.com/lightninglabs/lightning-agent-tools.git
cd lightning-agent-tools && skills/lnget/scripts/install.sh
lnget config init

# Call any bolthub API - lnget pays the invoice automatically
lnget --max-cost 100 https://acme.gw.bolthub.ai/v1/weather?city=berlin

Key flags:

FlagDescription
--max-cost <sats>Refuse to pay invoices above this amount
--no-payPreview mode - shows the invoice without paying
--verboseShow payment details and timing

lnget caches L402 tokens, so repeated requests to the same endpoint reuse existing tokens when valid. This makes it efficient for token_bucket and time_pass endpoints.

Every API listed in the bolthub API Hub is accessible via lnget. The gateway URL follows the pattern https://{slug}.gw.bolthub.ai{path}.

Using the CLI

The bolthub CLI lets you search, explore, and call APIs directly from the terminal. It is useful for testing, scripting, and CI/CD pipelines.

# Search the marketplace
bolthub search weather

# Check pricing before paying
bolthub info acme-weather

# Call a paid API (Lightning payment handled automatically)
bolthub call acme-weather /v1/forecast --max-cost 20

# POST with a JSON body
bolthub call ai-text /v1/analyze --method POST \
  --body '{"text": "Summarize this"}' --budget 500

Install globally or use with npx:

npm install -g @bolthub/cli
# or
npx @bolthub/cli search weather

The CLI uses the same wallet environment variables as the MCP tools (LND_REST_HOST, LNBITS_URL, NWC_URI, or PHOENIXD_URL). The search and info commands don't require a wallet; only call does.

Using paywalled endpoints from AI agents

AI agents follow the same L402 protocol as any other client. The flow has to be automated, and the agent needs a Lightning wallet it can call programmatically.

The easiest way to connect an AI agent is via the bolthub MCP server pointed at the gateway. Add this to your MCP client config (Cursor, Claude Desktop, or any MCP-compatible client):

{
  "mcpServers": {
    "acme-api": {
      "command": "npx",
      "args": ["-y", "@bolthub/mcp", "--gateway", "https://acme.gw.bolthub.ai"],
      "env": {
        "LND_REST_HOST": "https://your-lnd-node:8080",
        "LND_MACAROON": "<hex-admin-or-pay-macaroon>",
        "BUDGET_SATS": "1000"
      }
    }
  }
}

Wallet options: LND (recommended: bolthub Node Launcher or your own node) is shown above. You can also use LNBITS_URL + LNBITS_ADMIN_KEY, NWC_URI (easiest but slower 1-3s), or PHOENIXD_URL + PHOENIXD_PASSWORD if you already run Phoenixd for outbound payments. You only need one wallet type. BUDGET_SATS is optional and caps total spending per session (remove for unlimited). See the MCP server docs for details.

The server:

  1. Fetches the gateway's OpenAPI spec on startup
  2. Exposes each endpoint as an MCP tool with proper inputSchema
  3. Handles L402 payment transparently (402 → pay invoice → retry)
  4. Returns the API response as the tool result

The same server can also expose the whole bolthub marketplace and proxy your other MCP servers from one config entry; the --gateway form above is the minimal single-API setup.

You can get the ready-made config from:

  • The API Hub playground at https://bolthub.ai/hub/{slug}
  • The MCP config API at https://api.bolthub.ai/directory/{slug}/mcp-config
  • The gateway discovery at https://{slug}.gw.bolthub.ai/.well-known/mcp.json

Using the CLI

For quick testing or terminal-based workflows, the CLI offers search, info, and call commands that handle L402 payments automatically.

Using SDKs directly

For custom agent implementations, use @bolthub/pay (TypeScript) or the Python SDK.

Requirements

  1. A programmable Lightning wallet: the agent needs access to a wallet that can pay invoices via API. Options:

    • LND with REST access (recommended: spin up a node with the bolthub Node Launcher, or use your own LND)
    • LNbits with its HTTP API
    • Alby with the Alby API or NWC (Nostr Wallet Connect)
    • Phoenixd if you already use it for programmatic outbound payments (fast, self-custodial)
    • Any wallet that exposes a payinvoice API endpoint
  2. L402 parsing logic: the agent must parse the WWW-Authenticate header from the 402 response to extract the macaroon and invoice. Or use @bolthub/pay (TypeScript) / bolthub (Python), which handle this automatically.

Agent flow

Agent decides to call a tool/API


Send HTTP request to gateway URL


Receive 402 → parse WWW-Authenticate header

  ├── Extract: macaroon (base64)
  └── Extract: invoice (bolt11 string)


Pay invoice via wallet API → receive preimage


Retry request with Authorization: L402 {macaroon}:{preimage}


Receive 200 → use the data

Example: Python agent with LND

import httpx
import re

LND_HOST = "https://your-lnd-node:8080"
LND_MACAROON = "0201036c6e..."

GATEWAY_URL = "https://acme.gw.bolthub.ai/v1/weather"

def call_paywalled_api(url: str, params: dict = None) -> dict:
    resp = httpx.get(url, params=params)

    if resp.status_code != 402:
        return resp.json()

    www_auth = resp.headers["WWW-Authenticate"]
    macaroon = re.search(r'macaroon="([^"]+)"', www_auth).group(1)
    invoice = re.search(r'invoice="([^"]+)"', www_auth).group(1)

    pay_resp = httpx.post(
        f"{LND_HOST}/v1/channels/transactions",
        headers={"Grpc-Metadata-macaroon": LND_MACAROON},
        json={"payment_request": invoice},
    )
    preimage = pay_resp.json()["payment_preimage"]

    resp = httpx.get(
        url,
        params=params,
        headers={"Authorization": f"L402 {macaroon}:{preimage}"},
    )
    return resp.json()


data = call_paywalled_api(GATEWAY_URL, {"city": "berlin"})
print(data)

Example: TypeScript agent with Alby / NWC

const GATEWAY_URL = "https://acme.gw.bolthub.ai/v1/weather";

async function callPaywalledApi(url: string): Promise<unknown> {
  let resp = await fetch(url);

  if (resp.status !== 402) {
    return resp.json();
  }

  const wwwAuth = resp.headers.get("WWW-Authenticate")!;
  const macaroon = wwwAuth.match(/macaroon="([^"]+)"/)?.[1]!;
  const invoice = wwwAuth.match(/invoice="([^"]+)"/)?.[1]!;

  const { preimage } = await walletClient.payInvoice(invoice);

  resp = await fetch(url, {
    headers: { Authorization: `L402 ${macaroon}:${preimage}` },
  });

  return resp.json();
}

const data = await callPaywalledApi(`${GATEWAY_URL}?city=berlin`);

Tips for agent implementations

  • Budget guards: set a maximum sats-per-request and total sats budget to prevent runaway spending. Check the invoice amount before paying.
  • Invoice expiry: bolt11 invoices expire (typically in 60 seconds to 24 hours). If you wait too long, you'll need to re-request to get a fresh invoice.
  • Idempotency: an L402 macaroon and preimage pair is consumed by one successful call. Don't reuse it across different requests. The one exception is an upstream failure: the gateway makes the same proof redeemable again, so retrying that exact call is free (see Refunds and upstream failures).
  • Error handling: if the gateway returns 401 Unauthorized, your L402 token was invalid. Start the flow from step 1 again.
  • Token bucket optimization: if the endpoint uses token_bucket pricing, you pay once and get multiple requests. The remaining count is returned in the X-Session-Balance response header (it is not encoded in the token itself).
  • Upstream failures don't eat your payment: when the origin fails after you paid, the gateway automatically gives the payment back as service credit. The full policy is below.

Prepaid credit

Prepaid credit lets an agent pay once for a whole provider: a sats budget spendable across all of one provider's per_request endpoints. When an agent knows it will call several of a provider's endpoints, it sums their costs, buys that much credit in a single payment, and then makes its calls with no further payments until the budget runs out.

You pass a credit amount, not a price. Credit is face-value: the gateway opens exactly the budget you ask for, with no discount tiers. It echoes the honored budget back in the 402 challenge, and the official SDKs verify that echo before paying, so a provider that hasn't enabled credit is refused with nothing spent. Each later call burns the endpoint's real price against the budget, so there is no arbitrage across a provider's cheaper and dearer endpoints.

The SDKs buy the credit and then reuse it transparently:

# One payment; the credit then covers any of the provider's endpoints.
client.buy_credit("https://acme.gw.bolthub.ai/v1/data", 10_000)
client.get("https://acme.gw.bolthub.ai/v1/data")
client.get("https://acme.gw.bolthub.ai/v1/reports")
// batchFetch groups by provider and buys one credit per provider.
await client.batchFetch(
  ["https://acme.gw.bolthub.ai/v1/data", "https://bolt.gw.bolthub.ai/v1/prices"],
  { creditSats: 10_000 },
);

Credit is scoped to one provider. It never covers another, because a single Lightning payment settles to one provider's wallet: bolthub is a rail, not a vault, and never holds a pooled balance. Calling across several providers is several payments, one per provider, never one shared pot. Unused credit at expiry is non-refundable, so size it to what you expect to spend before the deadline. At the HTTP level, a credit purchase is an ordinary 402 flow with one extra request header, X-Bolthub-Credit: <sats>, on the initial call. You do not need this header if you use an SDK.

Refunds and upstream failures

Payment settles before the gateway forwards your request, so every way the origin can fail has a defined, automatic outcome. The rule: if the origin gave you nothing, the attempt costs nothing.

What happenedYour moneyHow to retry
Origin unreachable, 5xx, 408, or 429per_request: the consumed invoice is reverted and the same macaroon:preimage proof redeems again. Session models: the deducted unit or sats return to the session balance.Re-send the identical request with the same credential. It costs nothing.
Origin answered with another 4xx (400, 404, 422, …)Stays paid: the API gave a real answer about your request.Fix the request; the next call is a new payment.
Payment layer refused (expired invoice, already-consumed proof, depleted session)Nothing was charged.Start again from the 402 challenge.
Streaming (SSE)A stream that delivers zero bytes refunds the deduction after the fact. The restored balance shows on your next request.Reconnect with the same session token.

Refunds are service credit, applied automatically by the gateway. Sats never move back over Lightning: for sub-cent per-call prices, a free retry is the refund.

Payment status headers

The gateway makes the outcome machine-readable with two response headers:

HeaderValues
X-Bolthub-Paymentcharged, reverted, refunded_to_balance, not_charged
X-Bolthub-Payment-Codeupstream_failed_retryable, upstream_rejected, payment_failed

upstream_failed_retryable is the signal to act on: the payment layer already gave the money back, so re-sending the identical request is free. The official SDKs do this automatically with jittered backoff (opt out with retryOnUpstreamFailure: false in @bolthub/pay, retry_on_upstream_failure=False in Python). When the headers are absent, assume nothing about the payment; treat the failure like any other 5xx.

For API providers

A reverted invoice returns to settled and counts toward your revenue until the buyer's free retry consumes it. The sats landed in your wallet at pay time and never move back; what you owe is the retry. Requests that fail this way are never billed against the buyer's platform metering either: only successful (2xx) responses count.

HTTP reference

Request headers

HeaderWhen to sendFormat
AuthorizationOn retry after paymentL402 {macaroon_base64}:{preimage_hex}
X-Bolthub-CreditOn the initial call to buy prepaid creditCredit amount in sats (positive integer)

Response headers

HeaderWhen returnedFormat
WWW-AuthenticateOn 402 responsesL402 macaroon="{base64}", invoice="{bolt11}"
X-Session-TokenOn session creation (time_pass, metered, token_bucket, per_kb)Opaque session token string
X-Session-ExpiresOn session creationISO 8601 timestamp
X-Session-BalanceOn session responsesRemaining balance (sats or tokens)
X-Free-TryWhen free try is consumedused
X-CacheOn cacheable GET responsesHIT or MISS
X-Cache-AgeOn cache hitsSeconds since response was cached
X-Data-Size-KBOn per_kb responsesResponse size in kilobytes
X-Data-Cost-SatsOn per_kb responsesSats deducted for this response
X-Bolthub-PaymentOn paid-path responsesPayment disposition: charged, reverted, refunded_to_balance, not_charged
X-Bolthub-Payment-CodeOn paid-path failuresFailure class: upstream_failed_retryable, upstream_rejected, payment_failed
X-Request-IdOn all responsesUUID for request correlation

Rate limits

Paying requests are never rate-limited by IP. A request carrying payment credentials (an Authorization: L402 proof or an X-Session-Token) skips the per-IP limits below. Its real bound is economic: invoices are single-use and sessions draw down a prepaid budget. Only anonymous traffic (probes and 402 challenges) is IP-limited:

ScopeApplies toDefault limit
Per endpointAnonymous requests30/minute per IP (provider-configurable)
GlobalAnonymous requests300/minute per IP across all endpoints
Global backstopCredentialed requests3,000/minute per IP (abuse ceiling only)

Every 429 Too Many Requests carries a Retry-After header, and the official SDKs (@bolthub/pay, bolthub for Python, and the CLI/MCP tools built on them) wait it out and retry automatically — including after payment, where the gateway makes the same payment proof redeemable again so the retry is free.

One caveat: repeatedly presenting forged credentials (invalid macaroons, wrong preimages, tampered session tokens) demotes your IP to the anonymous limits for a minute. Expired or depleted sessions don't count. Re-paying when a session ends is the normal flow.

Running agent fleets

If you run many agents behind one egress IP (a NAT'd office, a serverless platform, a container fleet), size your integration around payments, not IPs:

  • Paid volume needs no special handling. Credentialed requests aren't IP-limited, so a fleet's combined paid throughput is bounded only by what it pays for.
  • Prefer session models for high volume. One 402 → one payment → a session token good for many calls (time pass, metered balance, or token bucket). The SDKs cache and reuse session tokens automatically, so the whole fleet can share a FileSessionStore (or any shared session store) and pay once instead of per call.
  • The anonymous limits only touch the challenge step. A fleet doing per-request payments performs one anonymous 402 challenge per call. At very high volume from a single IP, that step can hit the 300/min anonymous ceiling. Batch behind sessions, or spread challenge traffic across egress IPs; the paid retry itself is never the bottleneck.

Status codes

CodeMeaning
200Payment verified, response from origin API
400Bad request - invalid path, method, or request body
402Payment required - inspect WWW-Authenticate for invoice
401Invalid or expired L402 token - start the flow again
403Tenant is not active or suspended
404Endpoint not found or not active
413Request body exceeds the 1 MB limit
429Rate limited - too many requests, retry after backoff
502Origin API unreachable or connection error
503Service unavailable - wallet not configured or billing issue

Origin protection (for API providers)

When the gateway proxies a paid request to your origin, it injects headers you can use to verify the request is legitimate:

HeaderValuePurpose
X-Gateway-SecretYour tenant's gateway secret (from the dashboard)Static shared secret - reject requests that don't include it
X-Gateway-SignatureHMAC-SHA256 signature of the requestCryptographic proof the request was proxied by bolthub
X-Gateway-TimestampUnix timestamp (ms) when the signature was createdReject stale requests to prevent replay
X-Gateway-NonceUUID generated per requestReject duplicate requests to prevent replay

The signature is computed over a canonical payload:

METHOD\nPATH\nTIMESTAMP\nNONCE\nBODY

Where METHOD is the HTTP verb, PATH is the gateway path (e.g. /v1/weather), TIMESTAMP and NONCE are the values from the respective headers, and BODY is the raw request body (empty string for GET/HEAD).

For TypeScript/Node.js and Python origins, use the official verification packages instead of writing manual checks:

  • TypeScript: npm install @bolthub/verify. Zero runtime dependencies, uses only Node.js built-in crypto.
  • Python: pip install bolthub-verify. Zero runtime dependencies, with optional Flask / Django / FastAPI middleware.

Both packages handle HMAC signature verification, timestamp checks, replay prevention, and secret rotation out of the box.

The signature path is the cryptographic proof a request was proxied by bolthub. Use the official SDK when you can. It does the canonical-payload formatting, the timing-safe comparison, and the timestamp window for you.

TypeScript / Node.js (@bolthub/verify):

import { verifyGatewaySignature } from "@bolthub/verify";

const HMAC_SECRETS = [process.env.GATEWAY_HMAC_SECRET!]; // add the previous one during rotation

function handleProxiedRequest(req: { method: string; path: string; headers: Record<string, string>; body: string }) {
  const result = verifyGatewaySignature(
    { method: req.method, path: req.path, headers: req.headers, body: req.body },
    { secrets: HMAC_SECRETS, maxAgeMs: 30_000 },
  );
  if (!result.valid) {
    return new Response(JSON.stringify({ error: result.error }), { status: 403 });
  }
  // ... proxy to the origin handler
}

For Express, drop in the bundled middleware:

import express from "express";
import { expressHmacMiddleware } from "@bolthub/verify";

const app = express();
// Express's express.json()/raw() strip the raw body. Pass `rawBody`
// via a middleware like `express.raw({ type: "*/*" })` or save it on
// the request before json parsing so the verifier sees the exact bytes.
app.use(expressHmacMiddleware({ secrets: HMAC_SECRETS, maxAgeMs: 30_000 }));

Python (bolthub-verify):

import os
from bolthub_verify import verify_gateway_signature

HMAC_SECRETS = [os.environ["GATEWAY_HMAC_SECRET"]]  # add the previous one during rotation

def handle_proxied_request(request):
    headers = request.headers
    body = request.body.decode("utf-8") if isinstance(request.body, bytes) else (request.body or "")
    result = verify_gateway_signature(
        method=request.method,
        path=request.path,
        signature=headers.get("X-Gateway-Signature"),
        timestamp=headers.get("X-Gateway-Timestamp"),
        nonce=headers.get("X-Gateway-Nonce"),
        body=body,
        secrets=HMAC_SECRETS,
        max_age_ms=30_000,
    )
    if not result.valid:
        return Response(result.error or "Forbidden", status=403)
    # ... proxy to the origin handler

Framework middleware is included for Flask / Django / FastAPI:

from bolthub_verify import flask_hmac_middleware, django_hmac_middleware, fastapi_hmac_middleware

Each handles raw-body capture and returns a 403 on failure.

Verify X-Gateway-Secret (simple)

The static-secret path is a single header compare. Prefer the signature path when you can. The secret check is still useful as a cheap pre-filter, or for environments where you can't capture the raw request body.

TypeScript / Node.js:

import { verifyGatewaySecret } from "@bolthub/verify";

const result = verifyGatewaySecret(
  { method: req.method, path: req.path, headers: req.headers },
  { secrets: [process.env.GATEWAY_SECRET!] },
);
if (!result.valid) return res.status(403).end();

Python:

from bolthub_verify import verify_gateway_secret

result = verify_gateway_secret(
    header_value=request.headers.get("X-Gateway-Secret"),
    secrets=[os.environ["GATEWAY_SECRET"]],
)
if not result.valid:
    return Response("Forbidden", status=403)

Manual fallback (no SDK)

If you can't add the SDK to your origin, here's the canonical-payload recipe in plain Python. The TypeScript version has the same shape with Node's crypto.createHmac.

import hmac
import hashlib
import time

HMAC_SECRET = "your-hmac-secret-from-dashboard"
MAX_AGE_MS = 30_000

def verify_signature(request):
    signature = request.headers.get("X-Gateway-Signature")
    timestamp = request.headers.get("X-Gateway-Timestamp")
    nonce = request.headers.get("X-Gateway-Nonce")

    if not signature or not timestamp or not nonce:
        return Response("Forbidden", status=403)

    age_ms = int(time.time() * 1000) - int(timestamp)
    if age_ms > MAX_AGE_MS or age_ms < 0:
        return Response("Request expired", status=403)

    body = request.body or b""
    if isinstance(body, bytes):
        body = body.decode("utf-8")
    signing_payload = f"{request.method}\n{request.path}\n{timestamp}\n{nonce}\n{body}"

    expected = hmac.new(
        HMAC_SECRET.encode(), signing_payload.encode(), hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(signature, expected):
        return Response("Forbidden", status=403)

Header-based verification alone is insufficient if an attacker obtains your secrets. For defense in depth, restrict your origin to only accept traffic from bolthub gateway IPs:

  • Fly.io egress IPs: the gateway runs on Fly.io. Configure your firewall, security group, or WAF to only allow inbound traffic from Fly.io's published IP ranges.
  • Cloud providers: use security groups (AWS), firewall rules (GCP), or NSGs (Azure) to restrict your origin's listening port.
  • Cloudflare/reverse proxies: if your origin is behind a reverse proxy, configure an IP allowlist at the proxy level.

Combining IP restriction with signature verification ensures that even if secrets are leaked, your origin cannot be accessed directly.

Secret rotation

bolthub supports rotating your gateway and HMAC secrets without downtime. When you rotate secrets from the dashboard:

  1. The current secrets become "previous" and remain valid for a grace period.
  2. New secrets are generated and used for all new requests.
  3. Your origin should accept either the current or previous secret during the transition.

Both @bolthub/verify and bolthub-verify already accept a list of secrets and try each in turn with a timing-safe compare. Pass both your new and previous values during the rotation window:

verifyGatewaySignature(req, {
  secrets: [
    process.env.GATEWAY_HMAC_SECRET!,           // new
    process.env.GATEWAY_HMAC_SECRET_PREVIOUS!,  // previous, drop after the grace period
  ],
});
verify_gateway_signature(
    method=request.method, path=request.path,
    signature=signature, timestamp=timestamp, nonce=nonce, body=body,
    secrets=[
        os.environ["GATEWAY_HMAC_SECRET"],            # new
        os.environ["GATEWAY_HMAC_SECRET_PREVIOUS"],   # previous, drop after the grace period
    ],
)