api-key-rotator

A multi-provider API key rotation reverse proxy. Supports Firecrawl and Tavily profiles — each with its own key pool, upstream, route prefix, and rotation policy.

Why

firecrawl-mcp forwards all upstream calls to FIRECRAWL_API_URL. Pointing that at this proxy adds key rotation with zero changes to firecrawl-mcp - run the stock npx -y firecrawl-mcp.

Tavily integration works by sed-replacing the Tavily API URL inside OpenWebUI's source so Tavily requests flow through the proxy, picking keys from a separate pool (see "OpenWebUI + Tavily" below).

Run

docker compose up -d

With docker-compose.yml:

api-key-rotator:
  build: .
  environment:
    FIRECRAWL_API_KEYS: "fc-key1,fc-key2,fc-key3"
    UPSTREAM: "https://api.firecrawl.dev"
    PORT: "8788"
    MAX_PASSES: "2"

firecrawl:                     # your existing mcpo + firecrawl-mcp service
  environment:
    FIRECRAWL_API_URL: "http://api-key-rotator:8788"
    # FIRECRAWL_API_KEY removed - the rotator injects it

Configuration (env vars)

Var Default Purpose
FIRECRAWL_API_KEYS (required) Comma-separated key pool.
UPSTREAM https://api.firecrawl.dev Upstream Firecrawl API base.
UPSTREAM_PROXY (unset) Explicit forward proxy for egress (http/https/socks5/socks5h). With socks5h, DNS is resolved through the SOCKS5 server (same as socks5; the h form is accepted for curl/SearXNG-style configs). Wins over system vars.
HTTPS_PROXY / HTTP_PROXY / NO_PROXY (unset) System/curl-style proxy env, honored when UPSTREAM_PROXY is unset.
PORT 8788 Listen port.
HOST 0.0.0.0 Listen address.
MAX_PASSES 2 Full passes over the pool before giving up.
MAX_BODY_BYTES 16777216 (16 MiB) Cap on a buffered response body. Above it, forwarded untouched. 0 = no cap.
PROXY_BASE_URL (from Host header) Base used when rewriting next URLs.
CREDIT_RESET_DAY 1 Fallback day-of-month (1-31, UTC) when a key's billing reset can't be auto-detected. See "Credit disabling" below.
LOW_CREDIT_THRESHOLD 10 Switch off a key (rotate to the next) when its predicted remainingCredits drops to/below this.
STOP_CREDIT_THRESHOLD 2 Stop accepting requests when every key is below this. Must be <= LOW_CREDIT_THRESHOLD.
CREDIT_REFRESH_INTERVAL 300 Seconds; minimum interval between credit refreshes of a low-balance key. See "Credit-aware selection".
TAVILY_API_KEYS (unset) Comma-separated Tavily key pool. When set, enables the Tavily profile.
TAVILY_UPSTREAM https://api.tavily.com Upstream Tavily API base for the Tavily profile.
TAVILY_ROUTE_PREFIX /tavily Route prefix that selects the Tavily profile. Stripped before forwarding.
TAVILY_LOW_CREDIT_THRESHOLD 10 Same as LOW_CREDIT_THRESHOLD but for the Tavily key pool.
TAVILY_STOP_CREDIT_THRESHOLD 2 Same as STOP_CREDIT_THRESHOLD but for the Tavily key pool.
LOG_LEVEL info debug adds per-request lines.

Endpoints

  • GET /healthz -> 200 {"ok":true} if at least one key is usable, else 503. Docker healthcheck target.
  • GET /status -> pool size, current index, per-key stats, disabled state, and remainingCredits (keys masked to last 4 chars; -1 = unmeasured).

Credit-aware key selection

The rotator picks the key with the highest remaining credits that is above the stop threshold, so traffic concentrates on the healthiest account. It tracks each key's remainingCredits from GET /v2/team/credit-usage (read-only, costs no credits):

  • Startup: fetches every key's real balance in the background (the server starts immediately; until this completes, unmeasured keys are assumed plentiful).
  • On success: decrements the used key's predicted balance by the response's creditsUsed (or by 1 if that field is absent).
  • On rotation: refreshes the keys we switched off and onto.
  • Low balance: when a key's predicted balance drops below 100, it is refreshed at most once per CREDIT_REFRESH_INTERVAL (default 5 min) to correct estimation drift.
  • Daily: every key is refreshed once per 24h as a catch-all.

When the current key's predicted balance hits LOW_CREDIT_THRESHOLD (default 10), it is rotated off (cooled down ~30s) and the next-richest key takes over. When every key is below STOP_CREDIT_THRESHOLD (default 2), /healthz returns 503 and new requests return 503 {"success":false,"error":"all keys credit-exhausted until billing reset"}.

Rotation & retry behavior

Two kinds of failure are handled differently:

Key-level rejection -> rotate to another key: HTTP 402 (credits), 429 (rate limit), 401 (bad key), and failure envelopes ({"success":false,...}) whose text matches insufficient credits, rate limit, exceeded, payment required, unauthorized, forbidden. The key is cooled down ~30s (or disabled if credit-exhausted - see below) and the next key is tried, up to MAX_PASSES full sweeps.

Transient error -> backoff on the SAME key: HTTP 403 (edge/WAF), 408, 5xx, and network errors are retried on the same key with exponential backoff 500ms -> 1s -> 2s -> 4s -> 8s (5 attempts, ~15s total) before rotating. A 403 is usually a network/edge-layer issue, not a per-key problem, so it does NOT disable or rotate immediately.

A successful response (status < 400 with success:true, or no success field) never rotates - even if the scraped content mentions "rate limit" or "payment required". The denylist is checked against the Firecrawl failure envelope only, not the response body as a whole.

  • The next field's absolute upstream URL is rewritten to the proxy so crawl pagination stays under rotation. Other occurrences of the host in response bodies are never rewritten (they may be real scraped content).

Credit disabling

A key that returns a genuine credit-exhaustion signal (HTTP 402, or a success:false envelope mentioning insufficient credits / payment required / exceeded) is disabled and skipped on all subsequent requests until its credits reset - it is not retried every pass (which would waste upstream calls and risk account flags).

  • The reset instant is read per key from that key's own GET /v2/team/credit-usage -> billingPeriodEnd (a read-only endpoint that costs no credits). This matters because each key belongs to a separate account and resets on that account's billing anniversary, which is often a different day per key - not a universal date.
  • If the credit-usage call fails, the key is disabled until the next occurrence of CREDIT_RESET_DAY (UTC) as a fallback.
  • 429 (rate limit) and 401 (auth) rotate but do NOT disable - they are transient or account-global, and disabling on them would take a good key offline. 403 is retried with backoff, never disabled.
  • A background loop re-enables each key at its own reset instant; restarting the container also clears all disables.

Tavily profile

When TAVILY_API_KEYS is set, the proxy creates a separate key pool for Tavily. Requests whose path starts with TAVILY_ROUTE_PREFIX (default /tavily) are routed to the Tavily pool:

  • Prefix stripping: the leading /tavily is removed before forwarding. A request to /tavily/search hits {TAVILY_UPSTREAM}/search.
  • Rotation policy: HTTP 401 and 429 rotate the key (cool down ~30s) but do not disable. HTTP 432 and 433 disable the key until its credit reset (same reset mechanism as Firecrawl - per-key /api/usage or fallback CREDIT_RESET_DAY).
  • Body-based rejection detection: Tavily rejects are detected purely by status code, never by scanning response body text. The Firecrawl denylist never applies to Tavily responses.
  • Usage tracking: the proxy calls GET /usage on the Tavily upstream (per-key) to read key.usage / key.limit, account.plan_usage / plan_limit, and account.paygo_usage / paygo_limit. Effective remaining credits = min over layers of (limit - usage), skipping any layer whose limit <= 0 (unlimited/unmeasured). If all layers are unlimited the key stays unmeasured. Tavily returns no billing-period end, so a disabled key re-enables at the CREDIT_RESET_DAY fallback, same as the Firecrawl fallback path.
  • Credit thresholds: TAVILY_LOW_CREDIT_THRESHOLD and TAVILY_STOP_CREDIT_THRESHOLD mirror the Firecrawl thresholds but apply to the Tavily pool independently.

OpenWebUI + Tavily

To route OpenWebUI's Tavily searches through the proxy, override the Tavily API URL at container startup with sed:

services:
  open-webui:
    image: ghcr.io/open-webui/open-webui:main
    # ... your existing config ...
    command: >
      bash -c "
        sed -i \"s|https://api.tavily.com|http://api-key-rotator:8788/tavily|g\"
          /app/backend/open_webui/retrieval/web/tavily.py
          /app/backend/open_webui/retrieval/loaders/tavily.py
        && bash start.sh"

The /tavily prefix is stripped by the proxy before forwarding to https://api.tavily.com, so no other changes are needed.

Migration note

This project was originally named firecrawl-rotator. The rename to api-key-rotator affects:

Item Old New
Docker image ghcr.io/<you>/firecrawl-rotator ghcr.io/<you>/api-key-rotator
Compose service name firecrawl-rotator api-key-rotator
FIRECRAWL_API_URL host http://firecrawl-rotator:8788 http://api-key-rotator:8788
Binary / healthcheck /rotator -healthcheck /api-key-rotator -healthcheck

Update your docker-compose.yml service name, image tag, healthcheck path, and depends_on / FIRECRAWL_API_URL references accordingly.

Develop

go test ./...
go build -o api-key-rotator .
FIRECRAWL_API_KEYS=fc-x ./api-key-rotator

See docs/superpowers/specs/2026-07-09-firecrawl-token-rotation-design.md for the full design.