# Bitpoort MCP — integration guide for AI agents

> On-chain intelligence MCP for AI agents that act, not just read. 61 MCP tools and 206 REST paths (224 OpenAPI operations) live across Ethereum + Bitcoin + Hyperliquid (Polygon node live, public pipeline pending). Free instant API key, no signup. Snippets below are paste-ready — replace `bp_YOUR_KEY` with a key from `POST https://api.bitpoort.com/v1/keys`.

**Three things you only get here:**

1. **End-to-end AML workflow** — screen → assess → trace → monitor → signed report → public verify, across OFAC + UK + IL + JP (1,386 designated addresses), bridge-aware 3-hop tracing, retroactive sanctions exposure scanner.
2. **Agent-Safety contract** — pre-trade `validate_agent_action`, KYA signed agent identities, `dry_run` on every mutator, safety metadata on every response, per-customer audit log. The only MCP that builds agent safety into the protocol.
3. **Full Hyperliquid suite** — market, traders, funding arbs, liquidations, copy-trade, plus unique ETH ↔ HL cross-chain correlation.

310 regression tests. 100% across correctness / freshness / provenance / cross-tool consistency.

## 1. Get a key (no signup, no email)

```bash
curl -X POST https://api.bitpoort.com/v1/keys
```

Response includes:

- `api_key` — a `bp_` string
- `tier: free`
- `rate_limit: 60` (per minute, ~1 req/sec average)
- `rate_limit_per_second: 1`
- `daily_cap: 10000`
- `expires_at` (90 days out)
- `usage.docs` — https://docs.bitpoort.com
- `usage.agents_landing` — https://docs.bitpoort.com/agents
- `usage.discovery_manifest` — https://bitpoort.com/.well-known/mcp.json
- `usage.mcp.config` — paste-ready MCP client config

Limit: 50 instant keys per IP per day. For a permanent key with no daily creation cap, register with email at https://bitpoort.com/register. Test/CI workloads should bake one registered key into secrets rather than regenerating per run.

### Compliance report → PDF → verify workflow

Three tools, three calls, public-safe end-to-end:

```
1. generate_compliance_report(address=..., chain="ETH",
                              sections=[...],
                              dry_run=true)
   → {report: <full unsigned body>, body_sha256, signature: null,
      content_hash: null, would_persist: true}
   No DB write, no HMAC sign. Use to validate sections + see the
   exact body that would be signed.

2. export_compliance_pdf(content_hash=<hash from step 3 OR fixture>,
                         return_mode="metadata")
   → {mode: "metadata", size_bytes, pdf_sha256, signature_valid,
      template_version, rendered_at, sections, estimated_pages,
      pdf_base64: null, verify_url, canonical_body_url}
   Renders the PDF (so size + sha are real), strips the blob.
   Proves a full signed render works without downloading bytes.

   For the actual blob: omit return_mode (defaults to "binary")
   → returns the same metadata + pdf_base64.

3. GET /v1/verify/{content_hash}  (public, no auth)
   → returns the canonical body + signature so any third party can
     re-verify offline.
```

Stable fixture content_hash (no production mutation needed):
- See [`/fixtures.json`](https://mcp.bitpoort.com/fixtures.json) → `compliance_report_hashes.lazarus_full_sections`
- It's a real persisted report against a designated address; `compliance_reports` has no TTL so the hash stays valid indefinitely.

### Test fixtures + tools catalog (Phase 4)

Two static endpoints make end-to-end testing without guessing trivial:

- [`https://mcp.bitpoort.com/tools.json`](https://mcp.bitpoort.com/tools.json) — same payload an MCP client gets from `tools/list` (every tool's parameters schema, `_meta` safety flags from Phase 2A, plus `examples` per tool when populated). Public, no auth, 5-min cache.
- [`https://mcp.bitpoort.com/fixtures.json`](https://mcp.bitpoort.com/fixtures.json) — known-safe addresses, tx hashes, blocks, contracts, and HL symbols, plus expected outcomes for screen/risk/wallet_profile/rpc_get_transaction (used by the regression suite). Includes a 4-step sandbox lifecycle recipe for `register_agent_identity` testing without polluting production.

Use them together: read `tools.json` to discover the surface, then pull a known input from `mcp-fixtures.json` for any tool you want to live-test. Agent-identity tools accept `namespace="sandbox"` for a 24h-TTL test lane.

### Rate-limit contract (verified 2026-05-06)

The published 60/min cap is **steady-state guidance**, not a strict per-second floor:

- **Sustained**: 62 sequential requests at 1.049s min spacing returns 0 throttling responses. This is the contract pinned by `tests/integration/test_throttle.py::test_compliant_62_requests_min_spacing_1049ms` (nightly CI).
- **Bursts**: short bursts under the per-minute cap are absorbed without 429s — 70 concurrent requests against the liveness probe completed without explicit rate-limit responses (server-side back-pressure surfaced as 500s on a fraction of the burst). Liveness/health probes are intentionally exempted from per-key throttling on most platforms; data and MCP endpoints follow the per-minute window.
- **What this means for agents**: pace at ≤1 req/sec to never see a 429. Treat 429 as an authoritative signal to back off (recommended: exponential backoff starting at 2s). Do not assume a pure per-second token bucket — a 5-request burst is fine, a 70-request burst against a data endpoint will start shedding once the per-minute window saturates.

## 2. Plug into your MCP client

### Claude Code (1.0+, native HTTP MCP)

```bash
claude mcp add bitpoort \
  --transport http \
  --url https://mcp.bitpoort.com/ \
  --header "X-API-Key: bp_YOUR_KEY"
```

### Claude Desktop

Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows). Restart Claude Desktop after saving.

```json
{
  "mcpServers": {
    "bitpoort": {
      "type": "http",
      "url": "https://mcp.bitpoort.com/",
      "headers": {"X-API-Key": "bp_YOUR_KEY"}
    }
  }
}
```

### Cursor

Add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project). Reload Cursor after saving.

```json
{
  "mcpServers": {
    "bitpoort": {
      "url": "https://mcp.bitpoort.com/",
      "headers": {"X-API-Key": "bp_YOUR_KEY"}
    }
  }
}
```

### VS Code (1.99+ with Copilot Chat)

Add to `.vscode/mcp.json` in your workspace.

```json
{
  "servers": {
    "bitpoort": {
      "type": "http",
      "url": "https://mcp.bitpoort.com/",
      "headers": {"X-API-Key": "bp_YOUR_KEY"}
    }
  }
}
```

### Codex / OpenClaw (via mcp-remote stdio shim)

Add to `~/.codex/config.toml`. For OpenClaw containers, mount this file or bake into the image.

```toml
[mcp_servers.bitpoort]
command = "npx"
args = ["-y", "mcp-remote", "https://mcp.bitpoort.com/", "--header", "X-API-Key:bp_YOUR_KEY"]
```

### Anthropic SDK (Python, MCP Connector beta)

Sends `Authorization: Bearer` — pass your `bp_` key as `authorization_token`.

```python
import anthropic

client = anthropic.Anthropic()

response = client.beta.messages.create(
    model="claude-opus-4-7",
    max_tokens=1024,
    mcp_servers=[{
        "type": "url",
        "url": "https://mcp.bitpoort.com/",
        "name": "bitpoort",
        "authorization_token": "bp_YOUR_KEY",
    }],
    messages=[{"role": "user", "content": "What large ETH transfers happened in the last hour?"}],
    extra_headers={"anthropic-beta": "mcp-client-2025-04-04"},
)
```

### OpenAI Agents SDK (Python)

```bash
pip install openai-agents
```

```python
import asyncio
from agents import Agent, Runner
from agents.mcp import MCPServerStreamableHttp

bitpoort = MCPServerStreamableHttp(
    name="bitpoort",
    params={"url": "https://mcp.bitpoort.com/", "headers": {"X-API-Key": "bp_YOUR_KEY"}},
)

async def main():
    async with bitpoort as server:
        agent = Agent(name="onchain-analyst", mcp_servers=[server], model="gpt-4o")
        result = await Runner.run(agent, "Latest whale activity on ETH?")
        print(result.final_output)

asyncio.run(main())
```

### Plain JSON-RPC (curl)

```bash
# 1. Initialize -- capture the Mcp-Session-Id header
SESSION=$(curl -sD - -X POST https://mcp.bitpoort.com/ \
  -H "X-API-Key: bp_YOUR_KEY" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' \
  | awk -F': ' 'tolower($1)=="mcp-session-id"{gsub(/\r/,"",$2);print $2}')

# 2. List tools
curl -X POST https://mcp.bitpoort.com/ \
  -H "X-API-Key: bp_YOUR_KEY" -H "Mcp-Session-Id: $SESSION" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'

# 3. Call ask_blockchain
curl -X POST https://mcp.bitpoort.com/ \
  -H "X-API-Key: bp_YOUR_KEY" -H "Mcp-Session-Id: $SESSION" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ask_blockchain","arguments":{"question":"Recent whale activity on ETH","chain":"ETH"}}}'
```

## 3. All 61 MCP tools (12 lanes)

Canonical list — matches the live `tools/list` response. Schema for each tool (params, defaults, min/max bounds) is returned by `tools/list`. Every tool description is shaped as `[Lane] WHAT. USE WHEN: ... DON'T USE FOR: ... RETURNS: ...` — agent-routing infrastructure, not docs prose.

| Lane | Count | Tools |
|---|---|---|
| Forensic | 9 | `screen_address`, `assess_risk`, `detect_mixer_interactions`, `trace_flows`, `monitor_address`, `list_retroactive_alerts`, `dismiss_retroactive_alert`, `generate_compliance_report`, `export_compliance_pdf` |
| KYA / Know Your Agent | 4 | `validate_agent_action`, `register_agent_identity`, `list_my_agent_identities`, `revoke_agent_identity` |
| Whales & Entities | 4 | `get_whale_activity`, `get_btc_flows`, `get_entity_activity`, `get_wallet_profile` |
| Market signals | 8 | `get_trade_signals`, `get_conviction_signals`, `get_predictions`, `get_price_history`, `get_token_flows`, `get_exchange_flows`, `get_trending_tokens`, `get_emerging_tokens` |
| Hyperliquid perps | 6 | `get_hl_market`, `get_hl_traders`, `get_hl_funding_arbs`, `get_hl_liquidation_heatmap`, `get_hl_copy_trade_alerts`, `get_cross_chain_signals` |
| Alpha & smart money | 3 | `get_smart_wallets`, `get_mev_leaderboard`, `get_intelligence_feed` |
| Analytics | 2 | `get_signal_accuracy`, `get_signal_history` |
| Risk & alarms | 5 | `get_intent_detections`, `get_alarm_feed`, `get_alarm_categories`, `get_risk_briefing`, `check_abuse_status` |
| Direct RPC | 7 | `rpc_get_balance`, `rpc_get_block`, `rpc_get_transaction`, `rpc_get_logs`, `rpc_get_contract_info`, `rpc_read_contract`, `rpc_trace_transaction` |
| Data browsing | 3 | `query_blocks`, `query_transactions`, `inspect_block` |
| System & observability | 5 | `get_system_status`, `get_health`, `get_pipeline_status`, `get_stats`, `get_logs` |
| Q&A & overview | 3 | `ask_blockchain`, `get_network_summary`, `search_rag` |

Active total: **59**. Two deprecated (`get_alerts`, `get_chain_status`) still returned by `tools/list` for back-compat — replaced by `get_system_status(sections=[…])`, removed after 2026-09-01. Live count via `tools/list`: **61**.

### Standard query contract

Most list endpoints share the same filter + pagination contract:

| Param | Type | Notes |
|---|---|---|
| `chain` | string | Chain ID (`ETH`, `BTC`, `HL`, `POLYGON`) or `ALL` |
| `limit` | integer | Max results, 100-200 cap depending on endpoint |
| `min_usd` | number | Where applicable |
| `since` | string | ISO 8601 lower bound, e.g. `2026-05-01T00:00:00Z` |
| `until` | string | ISO 8601 upper bound |
| `cursor` | string | Opaque keyset paginator from a previous response's `next_cursor` |

Responses include `next_cursor` (null when last page) and `has_more`.

### Pagination per endpoint

Not every list endpoint is keyset-paginated. The two patterns:

| Pattern | Endpoints | Params |
|---|---|---|
| Keyset cursor + time window | `get_whale_activity`, `query_blocks`, `query_transactions`, `get_alarm_feed` and friends | `limit`, `since`, `until`, `cursor`, `next_cursor`, `has_more` |
| Limit + time window only | `get_emerging_tokens` (and the REST aliases `/v1/tokens/emerging`, `/v1/feed/emerging-tokens`, `/v1/intelligence/emerging`) | `limit`, `min_score`, `since`, `until` -- ranking is by float `score` so a stable opaque cursor isn't supported; page deeper by tightening `since`/`until` |

If an endpoint's response object contains `next_cursor`/`has_more`, it's keyset. If it returns a bare array (or a list under a fixed key without those fields), it's limit + time-window only.

### MCP tool aliases

Common guessed names resolve transparently to the canonical tool. No need to round-trip through `tools/list` first:

| You typed | Resolves to |
|---|---|
| `network_status` | `get_network_summary` |
| `chain_status` / `status` | `get_chain_status` |
| `recent_whales` / `whales` / `whale_activity` | `get_whale_activity` |
| `recent_blocks` / `latest_blocks` | `query_blocks` |
| `recent_transactions` / `latest_transactions` | `query_transactions` |
| `emerging_tokens` / `trending_tokens` | dropping `get_` works for any tool |
| `balance` / `block` / `transaction` / `tx` / `logs` / `trace` / `contract_info` / `read_contract` | the matching `rpc_*` tool |
| `rag` / `search` | `search_rag` |
| `ask` / `query` | `ask_blockchain` |
| `wallet` / `profile` | `get_wallet_profile` |
| `screen` / `sanctions` | `screen_address` |
| `mixer` / `mixers` | `detect_mixer_interactions` |
| `compliance` / `report` | `generate_compliance_report` |
| `risk` | `assess_risk` |
| `abuse` | `check_abuse_status` |
| `kya` | `validate_agent_action` |

If your guess doesn't resolve, the JSON-RPC error includes the 3 closest tool names by sequence-match distance plus the total tool count.

### Public REST endpoints (no API key required)

Most REST endpoints require `X-API-Key`. These are public:

| Path | Purpose |
|---|---|
| `POST /v1/keys` | Instant free-tier key creation |
| `GET /v1/health`, `GET /health` | JSON liveness + chain health |
| `GET /v1/trust` | Live eval baseline + accuracy + sanctions counts |
| `GET /v1/whales/recent` | Recent whale-tier transfers |
| `GET /v1/feed/emerging-tokens` | Emerging tokens ranked by momentum |
| `GET /v1/agent-identity/{name}` | KYA agent identity verify |
| `GET /v1/verify/{content_hash}` | Compliance report HMAC verify |
| `GET /openapi.json`, `GET /openapi-summary.json`, `GET /redoc` | Spec + summary + UI |
| `GET /v1/meta` | Prerendered HTML for the SPA discovery routes |

The published OpenAPI spec carries `security: []` on every public operation, so generated clients won't ask for a key on these.

## 4. Discover programmatically

| URL | Purpose |
|---|---|
| https://bitpoort.com/.well-known/mcp.json | MCP discovery manifest (endpoint, transport, auth, tool count, chains) |
| https://api.bitpoort.com/v1/trust | Live trust + accuracy baseline (public, no auth) |
| https://api.bitpoort.com/openapi.json | OpenAPI 3.1 spec (public, no auth) |
| https://api.bitpoort.com/redoc | ReDoc UI (public, no auth) |
| https://api.bitpoort.com/health | JSON liveness + chain health (public) |

## 5. One-shot smoke test

Each step lists the minimal fields an agent should assert. If your client returns the listed keys with sensible types and a tools/list count of 61, integration is healthy end-to-end.

```bash
KEY=$(curl -s -X POST https://api.bitpoort.com/v1/keys | python3 -c "import sys,json;print(json.load(sys.stdin)['api_key'])")
# Expected: KEY starts with "bp_". POST /v1/keys returns:
# {"api_key":"bp_...", "tier":"free", "rate_limit":60, "rate_limit_per_second":1, "daily_cap":10000,
#  "scopes":["mcp","api","health","wallets","blocks","whales","swaps"],
#  "expires_at":"<ISO 8601>", "usage":{"header":"X-API-Key", ...}}

# Auth identity
curl -H "X-API-Key: $KEY" https://api.bitpoort.com/v1/auth/me
# Expected: {"api_key_id":<int>, "name":"instant-agent", "role":"agent",
#            "rate_limit":60, "scopes":[...], "sso":null}

# Sample REST query
curl -H "X-API-Key: $KEY" "https://api.bitpoort.com/v1/whales/recent?chain=ETH&limit=3"
# Expected: {"count":<=3>, "chain":"ETH", "min_usd":100000.0,
#            "next_cursor":<string|null>, "has_more":<bool>,
#            "whales":[{"tx_hash":"0x...", "block_number":<int>,
#                       "from_address":"0x...", "to_address":"0x...",
#                       "from_entity":<string|null>, "to_entity":<string|null>,
#                       "value_usd":<float>, "token_symbol":<string>,
#                       "tier":"LARGE|MEGA|...", "timestamp":"<ISO>"}, ...]}

# MCP initialize -- capture session id
SESSION=$(curl -sD - -X POST https://mcp.bitpoort.com/ \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke","version":"1"}}}' \
  | awk -F': ' 'tolower($1)=="mcp-session-id"{gsub(/\r/,"",$2);print $2}')
# Expected: SESSION is a UUID. Response body is JSON-RPC:
# {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05",
#  "capabilities":{"tools":{"listChanged":false}},
#  "serverInfo":{"name":"bitpoort","version":"3.5.0"}, ...}}.

# MCP tools/list
curl -X POST https://mcp.bitpoort.com/ \
  -H "X-API-Key: $KEY" -H "Mcp-Session-Id: $SESSION" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
# Expected: {"jsonrpc":"2.0","id":2,"result":{"tools":[
#   {"name":"<tool>","description":"...","inputSchema":{"type":"object",...}},
#   ...]}}. len(result.tools) == 61.

# MCP tool call
curl -X POST https://mcp.bitpoort.com/ \
  -H "X-API-Key: $KEY" -H "Mcp-Session-Id: $SESSION" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_network_summary","arguments":{"chain":"ETH"}}}'
# Expected: {"jsonrpc":"2.0","id":3,"result":{"content":[
#   {"type":"text","text":"<JSON string>"}]}}.
# Parse content[0].text as JSON, then assert:
# {"chain":"ETH","health_score":<int>,"health_status":"HEALTHY|...",
#  "latest_block":<int>,"latest_block_time":"<ISO>",
#  "hourly":{"tx_count":<int>,"block_count":<int>,"volume_usd":<float>,"avg_gas":<float>},
#  "whales_1h":{"count":<int>,"total_usd":<float>,"top":[...]}}.
```

If all five return successful JSON matching the expected shapes, integration works end-to-end.

## 6. OAuth 2.1 — for hosted MCP directories (Anthropic, Open WebUI)

If you're integrating Bitpoort as a connector in the Anthropic agent
directory (or any other host that initiates an OAuth handshake before
hitting the MCP endpoint), use the OAuth lane instead of `bp_` keys.
The X-API-Key lane stays available; OAuth is purely additive.

Auto-discovery URL the directory should fetch first:

```
GET https://api.bitpoort.com/.well-known/oauth-authorization-server
```

That returns RFC 8414 metadata listing every endpoint, supported
scopes, grant types, and PKCE methods. The directory plugs those URLs
into its OAuth client and the rest of the flow runs standard:

| Step | Endpoint | Notes |
|---|---|---|
| 1. Authorize | `GET https://api.bitpoort.com/oauth/authorize?response_type=code&client_id=…&redirect_uri=…&code_challenge=…&code_challenge_method=S256&scope=mcp:read+api:read&state=…` | Renders the Bitpoort consent screen. User approves; we 302 back with `?code=…&state=…`. PKCE S256 required. |
| 2. Token exchange | `POST https://api.bitpoort.com/oauth/token` | Form body: `grant_type=authorization_code`, `code`, `redirect_uri`, `code_verifier`, `client_id`, `client_secret` (basic-auth or form). Returns `access_token` + `refresh_token` + `expires_in`. |
| 3. Use token | `Authorization: Bearer <access_token>` against `https://mcp.bitpoort.com/` or `https://api.bitpoort.com/v1/*` | Same MCP endpoint, same REST API, same 61 tools. The bearer is opaque (not a JWT) and never reveals the user's `bp_` key. |
| 4. Refresh | `POST https://api.bitpoort.com/oauth/token` with `grant_type=refresh_token` | Refresh tokens rotate -- the response carries a NEW refresh_token; the old one is single-use. Re-using a rotated refresh revokes the entire chain (OAuth 2.1 BCP). |
| 5. Revoke | `POST https://api.bitpoort.com/oauth/revoke` | Pass either an access or a refresh token; we'll revoke whichever matches. |

### Scopes you can request

| Scope | Use it when... |
|---|---|
| `mcp:read` | Your agent calls Bitpoort MCP tools (whales, smart money, sanctions, RAG, predictions). |
| `api:read` | Your agent calls Bitpoort REST endpoints (blocks, feeds, OpenAPI surface). |
| `profile:read` | You want to render the user's plan tier / display name. |
| `openid` | OIDC-style discovery compatibility. |
| `email` | You want to display the user's account email. |

A token's effective grant is the intersection of (requested ∩
client.allowed_scopes ∩ supported). Asking for a scope you haven't
been registered for silently drops it from the grant -- the granted
scope set comes back in the token response so you can verify before
proceeding.

### Token lifetimes

- Access token: **1 hour**.
- Refresh token: **30 days**, rotated on every use.
- Authorization code: **10 minutes**, single use.

### Becoming a registered OAuth client

Manual at MVP -- email Bitpoort with your callback URL(s) and
requested scopes, we provision a `client_id` + (for confidential
clients) a `client_secret`. Public clients (PKCE-only, e.g. native
apps) skip the secret. Dynamic Client Registration (RFC 7591) is on
the roadmap once the directory submission flow stabilises.

### Quick smoke test

```bash
# 1. Fetch the metadata document (no auth, public)
curl -s https://api.bitpoort.com/.well-known/oauth-authorization-server \
  | python3 -m json.tool | head -25

# 2. Verify scopes_supported byte-matches what your client requests
curl -s https://api.bitpoort.com/.well-known/oauth-authorization-server \
  | python3 -c "import sys,json; print(json.load(sys.stdin)['scopes_supported'])"
# Expected: ['mcp:read', 'api:read', 'profile:read', 'openid', 'email']

# 3. Once you have an access_token, hit /oauth/userinfo
curl -s https://api.bitpoort.com/oauth/userinfo \
  -H "Authorization: Bearer <access_token>"
# Expected: {"sub":"<account_id>", "plan":"free|pro|enterprise",
#            "scopes":[...], "email":"...", "name":"..."}

# 4. Use the same bearer against the MCP endpoint
curl -s https://mcp.bitpoort.com/ \
  -H "Authorization: Bearer <access_token>" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"oauth-smoke","version":"1"}}}'
# Expected: same shape as the X-API-Key smoke test above.
```

Both lanes (`bp_` and OAuth) follow the same authentication contract
-- response headers, error codes, principal shape, and scope
semantics are byte-identical. The X-RateLimit-* headers documented
in section 1 fire on both lanes; the AUTH_REQUIRED / AUTH_INVALID /
RATE_LIMIT_KEY / DAILY_CAP error codes are stable across both. Pick
whichever fits your integration; the API surface underneath does not
care which credential you presented.
