A production-grade FastAPI proxy in front of the Solana JSON-RPC API, adding API-key auth, Redis-backed sliding-window rate limiting, structured error handling, and signed webhooks for real-time account-change notifications.
Built to demonstrate the difference between "a script that calls an API" and "a service you'd actually put in front of production traffic": bounded retries, atomic rate limiting, observability, graceful shutdown, and tests that actually exercise the failure paths.
Public and even paid Solana RPC endpoints throttle hard, and every serious Solana app ends up needing the same infrastructure: a layer that absorbs rate limits, retries transient failures, and turns raw JSON-RPC errors into something a client can actually branch on. This proxy is that layer, plus webhook-based account watching so consumers don't have to manage their own WebSocket subscriptions.
┌─────────────────────────────────────────┐
│ FastAPI App │
│ │
Client ──POST──▶ │ RequestContext │ RateLimit │ CORS │
/rpc │ middleware middleware │
│ │ │
│ ▼ │
│ /rpc route ──▶ SolanaRpcClient ──▶ Upstream │
│ (retries, timeouts) Solana │
│ RPC │
│ /webhooks route ──▶ AccountSubscriptionMgr │
│ │ │ │
│ ▼ ▼ │
│ Redis Upstream Solana │
│ (subscriptions, WebSocket │
│ rate limit state, (accountSubscribe) │
│ delivery log) │ │
│ ▼ │
│ WebhookDeliveryService │
│ (HMAC-signed POST, │
│ exponential backoff) │
│ │ │
└──────────────────────────────┼────────────────┘
▼
Your callback_url
Key components:
| Component | File | Responsibility |
|---|---|---|
| RPC proxy | app/routes/rpc.py, app/services/solana_client.py |
Forwards JSON-RPC 2.0 calls upstream with retries/timeouts |
| Rate limiter | app/services/rate_limiter.py |
Atomic sliding-window limiting via a Redis Lua script |
| Webhook manager | app/services/account_subscription_manager.py |
Owns upstream accountSubscribe WebSocket connections, fans out to subscribers |
| Webhook delivery | app/services/webhook_delivery.py |
HMAC-signs and delivers webhook payloads with exponential backoff |
| Error handling | app/core/exceptions.py, app/core/error_handlers.py |
Normalizes every failure mode into one consistent JSON error shape |
- Transparent JSON-RPC proxy — point any
@solana/web3.jsConnectionat this URL; the request/response shape is unchanged - Sliding-window rate limiting — a Redis Lua script makes the check-and-increment atomic, avoiding the 2x-burst-at-window-boundary bug of naive fixed-window counters
- Signed webhooks for account changes — register a
callback_url, get an HMAC-SHA256-signed POST every time a watched account's balance or data changes - Structured errors — every error response has the same shape (
error_code,message,details,request_id), whether it originated from validation, rate limiting, or the upstream Solana node - Bounded retries — transient upstream failures (timeouts, connection resets) are retried with backoff; a valid JSON-RPC error is never retried
- Observability — JSON structured logs, request-ID correlation, Prometheus metrics at
/metrics - Graceful shutdown — WebSocket listeners and delivery tasks are cancelled cleanly on SIGTERM
git clone <this-repo>
cd solana-rpc-proxy
cp .env.example .env
# edit .env: set API_KEYS and WEBHOOK_SIGNING_SECRET to real values
docker compose up --buildThe API is now live at http://localhost:8000. Interactive docs at
http://localhost:8000/docs (disabled automatically when ENVIRONMENT=production).
# Proxy a JSON-RPC call
curl -X POST http://localhost:8000/rpc \
-H "X-API-Key: dev-local-key-change-me" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"getSlot","params":[]}'
# Watch an account for balance/data changes
curl -X POST http://localhost:8000/webhooks \
-H "X-API-Key: dev-local-key-change-me" \
-H "Content-Type: application/json" \
-d '{
"account_address": "11111111111111111111111111111111",
"callback_url": "https://your-server.example.com/hook",
"commitment": "confirmed"
}'Transparent JSON-RPC 2.0 proxy. Requires X-API-Key. Blocks sendTransaction
and requestAirdrop by default (edit _BLOCKED_METHODS in app/routes/rpc.py
if you want to allow them through this proxy — think carefully before you do).
Register a webhook. Body:
{
"account_address": "base58 pubkey",
"callback_url": "https://...",
"commitment": "processed | confirmed | finalized",
"label": "optional string"
}Returns a subscription_id. Every account change triggers a POST to
callback_url with body:
{
"event": "account_change",
"subscription_id": "...",
"account_address": "...",
"lamports": 123456,
"owner": "...",
"executable": false,
"rent_epoch": 361,
"data_base64": "...",
"slot": 123456789,
"timestamp": "2026-08-02T12:00:00+00:00"
}and header X-Webhook-Signature: an HMAC-SHA256 hex digest of the raw request
body, keyed with WEBHOOK_SIGNING_SECRET. Verify this before trusting the
payload — anyone can POST to your endpoint pretending to be this proxy:
import hmac, hashlib
def verify(secret: str, raw_body: bytes, signature_header: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature_header)List, inspect, and remove subscriptions.
Last 50 delivery attempts for a subscription — status codes, errors, timestamps.
Useful for debugging a flaky callback_url.
/health is a bare liveness check (no auth, no dependency checks). /health/ready
additionally pings Redis and returns 503 if it's unreachable — use this one for
your load balancer's readiness probe.
Prometheus-format metrics: request latency/count/status by route (via
prometheus-fastapi-instrumentator), plus solana_proxy_rpc_requests_total,
solana_proxy_webhook_deliveries_total, solana_proxy_rate_limit_rejections_total.
All configuration is environment variables (see .env.example for the full
list with defaults). The important ones:
| Variable | Default | Notes |
|---|---|---|
SOLANA_RPC_URL |
api.mainnet-beta.solana.com |
Point at Helius/QuickNode for anything beyond light testing |
REDIS_URL |
redis://localhost:6379/0 |
Required — the app refuses to start if unreachable |
API_KEYS |
(empty) | Comma-separated; set REQUIRE_API_KEY=false to disable auth entirely (not recommended) |
RATE_LIMIT_REQUESTS / RATE_LIMIT_WINDOW_SECONDS |
60 / 60 |
Per-API-key (or per-IP if unauthenticated) |
WEBHOOK_SIGNING_SECRET |
(placeholder — change this) | HMAC key for signing outgoing webhook payloads |
pip install -r requirements-dev.txt
# Needs a local Redis reachable at REDIS_URL (or run via Docker below)
pytest -v --cov=app --cov-report=term-missingOr fully containerized, no local Redis needed:
docker compose -f docker-compose.test.yml up --build \
--abort-on-container-exit --exit-code-from testsTests mock the upstream Solana RPC with respx and use fakeredis for
Redis, so the suite runs with no real network calls except within the
Docker/CI Redis service container itself.
- Single-instance webhook ownership.
AccountSubscriptionManagerholds upstream WebSocket connections and subscriber state in process memory. Running multiple replicas of this service would cause each replica to independently open its ownaccountSubscribeconnection for the same account, delivering every webhook multiple times. This is why the Docker image runs with--workers 1. Scaling this out requires a coordination layer (e.g., a Redis lock peraccount_addressso only one replica owns the upstream subscription, with events published to the others over Redis pub/sub) — a natural next step, deliberately left out of scope here to keep the core proxy logic legible. - Webhook retries are in-memory, not durable. If the process restarts mid-retry-backoff, an in-flight delivery attempt is lost (though the subscription itself survives via Redis and will resume delivering on the next account change). A durable queue (e.g. Redis Streams or a proper task queue) would close this gap for high-reliability use cases.
- No persistent request-level API key store. Keys are a static list from
config.
app/core/security.pyisolates this behind one function so swapping in a database-backed key store with per-key usage tracking is a contained change.
FastAPI · Redis (redis.asyncio) · httpx · websockets · Pydantic v2 ·
Prometheus · Docker
MIT — see LICENSE.