{"openapi":"3.0.0","info":{"title":"Circles Referrals API","version":"1.0.0","description":"## Referral link backend for Circles\n\nStores private keys that power invitation links. The SDK generates a keypair locally, stores the private key here, and encodes it into a link. When someone opens the link, the frontend retrieves the inviter via this API.\n\n### How it works\n\n**1. Create invitation** — SDK calls `POST /store` with a private key + inviter address. The backend derives the signer address and stores the mapping.\n\n**2. Share link** — The private key is encoded into a URL. The invitee opens it in the browser.\n\n**3. Retrieve inviter** — Frontend calls `GET /retrieve?key=0x...` to get the inviter address.\n\n**4. Claim on-chain** — The invitee creates a Circles account. The indexer detects `AccountCreated` / `AccountClaimed` events and updates the referral status.\n\n### Authentication\n\nMost endpoints are public (rate limited by IP). `GET /my-referrals` requires a JWT with `aud=referrals-api` from the Circles Auth Service.\n\n**How to get a JWT for this API:**\n\nOption A — Wallet (first-time users):\n1. `POST auth-service/challenge` with `{ address, audience: \"referrals-api\" }`\n2. Sign the SIWE message with the user's wallet\n3. `POST auth-service/verify` → JWT with `aud=referrals-api`\n\nOption B — Passkey (returning users, recommended):\n1. `POST auth-service/passkey/authenticate/options` with `{ address, audience: \"referrals-api\" }`\n2. User taps passkey\n3. `POST auth-service/passkey/authenticate/verify` → JWT with `aud=referrals-api`\n\nSend: `Authorization: Bearer <token>`\n\n### Referral status lifecycle\n\n`pending` → `confirmed` → `claimed`\n\n- **pending** — stored, waiting for on-chain account creation\n- **confirmed** — indexer detected `AccountCreated` event for the derived address\n- **stale** — no on-chain activity after 1 hour (cleanup job marks as stale, still usable)\n- **claimed** — indexer detected `AccountClaimed` event (invitation fully used, link becomes 410 Gone)"},"servers":[{"url":"","description":"Production (via reverse proxy or DO App Platform)"},{"url":"http://localhost:8080","description":"Local development"}],"tags":[{"name":"Distributions","description":"Session-based key distribution. Create sessions with quota/expiry, add keys, dispense via `GET /d/{slug}` for QR codes. Each session is a scoped 'window' into an inviter's key pool with pause controls. The slug acts as a capability token."},{"name":"Referrals","description":"**Core API** — store, retrieve, and list invitation referral mappings. Public endpoints (store, retrieve, list, balance, distribute) require no authentication. The authenticated endpoint (my-referrals) requires a JWT with `aud=referrals-api` from the Circles Auth Service (via SIWE for new users or passkey for returning users)."},{"name":"Authentication","description":"JWT authentication for protected endpoints (`/my-referrals`, `/distributions/sessions`). Get a token from the Circles Auth Service via SIWE wallet signature or passkey. Send: `Authorization: Bearer <token>` with `aud=referrals-api`."},{"name":"Health","description":"Liveness and readiness probes for orchestration (Kubernetes, DO health checks)."}],"components":{"schemas":{},"parameters":{}},"paths":{"/referrals/health":{"get":{"tags":["Health"],"summary":"Basic health check","description":"Check if the service is running. For Kubernetes, prefer /live and /ready.","responses":{"200":{"description":"Service is healthy","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"timestamp":{"type":"string","format":"date-time"}},"required":["status","timestamp"]}}}}}}},"/referrals/health/live":{"get":{"tags":["Health"],"summary":"Liveness probe","description":"Kubernetes liveness probe. Returns 200 if the process is alive. If this fails, Kubernetes should restart the container.","responses":{"200":{"description":"Process is alive","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok","error"]},"timestamp":{"type":"string","format":"date-time"}},"required":["status","timestamp"]}}}}}}},"/referrals/health/ready":{"get":{"tags":["Health"],"summary":"Readiness probe","description":"Kubernetes readiness probe. Returns 200 if the service can serve traffic. Checks database connectivity. If this fails, Kubernetes should stop routing traffic.","responses":{"200":{"description":"Service is ready to accept traffic","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok","degraded","error"]},"timestamp":{"type":"string","format":"date-time"},"checks":{"type":"object","properties":{"database":{"type":"object","properties":{"status":{"type":"string","enum":["ok","error"]},"latencyMs":{"type":"number"},"error":{"type":"string"},"pool":{"type":"object","properties":{"max":{"type":"number"},"idleTimeoutSeconds":{"type":"number","nullable":true}},"required":["max","idleTimeoutSeconds"]}},"required":["status"]},"referralsModule":{"type":"object","properties":{"status":{"type":"string","enum":["ok","warning","skipped"]},"configured":{"type":"boolean"},"address":{"type":"string"},"skipValidation":{"type":"boolean"},"warning":{"type":"string"}},"required":["status","configured","skipValidation"]},"authService":{"type":"object","properties":{"status":{"type":"string","enum":["ok","error"]},"latencyMs":{"type":"number"},"error":{"type":"string"}},"required":["status"]},"indexer":{"type":"object","properties":{"status":{"type":"string","enum":["ok","degraded","down"]},"websocket":{"type":"object","properties":{"connected":{"type":"boolean"},"readyState":{"type":"string"},"subscriptionId":{"type":"string","nullable":true},"reconnectAttempts":{"type":"number"},"maintenanceMode":{"type":"boolean"},"shuttingDown":{"type":"boolean"},"lastMessageAgeMs":{"type":"number","nullable":true},"lastEventAgeMs":{"type":"number","nullable":true}},"required":["connected","readyState","subscriptionId","reconnectAttempts","maintenanceMode","shuttingDown","lastMessageAgeMs","lastEventAgeMs"]},"polling":{"type":"object","properties":{"active":{"type":"boolean"},"intervalMs":{"type":"number"}},"required":["active","intervalMs"]}},"required":["status","websocket","polling"]}},"required":["database","authService"]}},"required":["status","timestamp","checks"]}}}},"503":{"description":"Service is not ready","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok","degraded","error"]},"timestamp":{"type":"string","format":"date-time"},"checks":{"type":"object","properties":{"database":{"type":"object","properties":{"status":{"type":"string","enum":["ok","error"]},"latencyMs":{"type":"number"},"error":{"type":"string"},"pool":{"type":"object","properties":{"max":{"type":"number"},"idleTimeoutSeconds":{"type":"number","nullable":true}},"required":["max","idleTimeoutSeconds"]}},"required":["status"]},"referralsModule":{"type":"object","properties":{"status":{"type":"string","enum":["ok","warning","skipped"]},"configured":{"type":"boolean"},"address":{"type":"string"},"skipValidation":{"type":"boolean"},"warning":{"type":"string"}},"required":["status","configured","skipValidation"]},"authService":{"type":"object","properties":{"status":{"type":"string","enum":["ok","error"]},"latencyMs":{"type":"number"},"error":{"type":"string"}},"required":["status"]},"indexer":{"type":"object","properties":{"status":{"type":"string","enum":["ok","degraded","down"]},"websocket":{"type":"object","properties":{"connected":{"type":"boolean"},"readyState":{"type":"string"},"subscriptionId":{"type":"string","nullable":true},"reconnectAttempts":{"type":"number"},"maintenanceMode":{"type":"boolean"},"shuttingDown":{"type":"boolean"},"lastMessageAgeMs":{"type":"number","nullable":true},"lastEventAgeMs":{"type":"number","nullable":true}},"required":["connected","readyState","subscriptionId","reconnectAttempts","maintenanceMode","shuttingDown","lastMessageAgeMs","lastEventAgeMs"]},"polling":{"type":"object","properties":{"active":{"type":"boolean"},"intervalMs":{"type":"number"}},"required":["active","intervalMs"]}},"required":["status","websocket","polling"]}},"required":["database","authService"]}},"required":["status","timestamp","checks"]}}}}}}},"/referrals/store":{"post":{"tags":["Referrals"],"summary":"Store a referral mapping","description":"Store a mapping from inviter address to private key. Called by SDK when generating referral links. Only format validation is performed (64 hex chars). On-chain account existence is verified at claim time via indexer events. The inviter address is self-declared for dashboard visibility only - the on-chain indexer captures the true inviter from contract events.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"privateKey":{"type":"string","pattern":"^0x[a-fA-F0-9]{64}$","description":"Hex-encoded secp256k1 private key (64 hex chars, 0x-prefixed). The SDK generates this keypair locally — the private key becomes the invitation link, and the derived public address is used to track the referral on-chain. WARNING: This is stored in the database. Do NOT reuse keys from real wallets.","example":"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"},"inviter":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"The inviter's Ethereum address (EOA or Safe). Displayed in the dashboard under 'my referrals'. This is self-declared — the on-chain indexer captures the true inviter from contract events at claim time, so spoofing this only affects dashboard display.","example":"0x1234567890123456789012345678901234567890"}},"required":["privateKey","inviter"],"example":{"privateKey":"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef","inviter":"0x1234567890123456789012345678901234567890"}}}}},"responses":{"200":{"description":"Referral stored successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}},"required":["success"]}}}},"400":{"description":"Bad request - invalid private key format","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}}}}},"/referrals/store-batch":{"post":{"tags":["Referrals"],"summary":"Store multiple referrals in batch","description":"Store multiple invitation mappings in a single request. More efficient than multiple individual /store calls. Only format validation is performed. Returns success if at least one invitation was stored successfully.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"invitations":{"type":"array","items":{"type":"object","properties":{"privateKey":{"type":"string","pattern":"^0x[a-fA-F0-9]{64}$","description":"Hex-encoded secp256k1 private key (64 hex chars, 0x-prefixed). The SDK generates this keypair locally — the private key becomes the invitation link, and the derived public address is used to track the referral on-chain. WARNING: This is stored in the database. Do NOT reuse keys from real wallets.","example":"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"},"inviter":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"The inviter's Ethereum address (EOA or Safe). Displayed in the dashboard under 'my referrals'. This is self-declared — the on-chain indexer captures the true inviter from contract events at claim time, so spoofing this only affects dashboard display.","example":"0x1234567890123456789012345678901234567890"}},"required":["privateKey","inviter"]},"minItems":1,"maxItems":200,"description":"Array of 1-200 invitations to store in a single request. Each invitation has the same format as the /store endpoint. Processing is independent — one failure doesn't block others."}},"required":["invitations"],"example":{"invitations":[{"privateKey":"0x1111111111111111111111111111111111111111111111111111111111111111","inviter":"0x1234567890123456789012345678901234567890"},{"privateKey":"0x2222222222222222222222222222222222222222222222222222222222222222","inviter":"0x1234567890123456789012345678901234567890"}]}}}}},"responses":{"200":{"description":"Batch processing complete (some or all invitations stored)","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","description":"true if at least one invitation was stored. false only if ALL failed (returns 400)."},"stored":{"type":"number","description":"Number of invitations successfully stored in the database."},"failed":{"type":"number","description":"Number of invitations that failed validation or storage (see `errors` array for details)."},"errors":{"type":"array","items":{"type":"object","properties":{"index":{"type":"number","description":"Zero-based index of the failed invitation in the request array."},"keyPreview":{"type":"string","description":"Masked private key for identification (first 10 + last 4 chars). Never exposes the full key.","example":"0x1234567...cdef"},"reason":{"type":"string","description":"Why this invitation failed (e.g. 'Duplicate key', 'Rate limit exceeded')."}},"required":["index","keyPreview","reason"]},"description":"Details of failed invitations. Only present if `failed > 0`."}},"required":["success","stored","failed"]}}}},"400":{"description":"Bad request - invalid input or all invitations failed","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}}}}},"/referrals/retrieve":{"get":{"tags":["Referrals"],"summary":"Retrieve inviter by private key","description":"Get the inviter address and status for a given private key. Public endpoint for referral link flow.","parameters":[{"schema":{"type":"string","pattern":"^0x[a-fA-F0-9]{64}$","description":"The private key from the invitation link. The frontend extracts this from the URL and sends it here to look up who invited the user. Returns the inviter address and referral status. Returns 410 if already claimed.","example":"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"},"required":true,"name":"key","in":"query"}],"responses":{"200":{"description":"Referral found","content":{"application/json":{"schema":{"type":"object","properties":{"inviter":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"The inviter's Ethereum address who created this referral."},"status":{"type":"string","enum":["pending","stale","confirmed","claimed"],"description":"Referral lifecycle status: 'pending' = stored, waiting for on-chain activity. 'confirmed' = indexer detected AccountCreated event. 'stale' = no on-chain activity after 1h (still usable, reverts to pending on activity). 'claimed' = AccountClaimed event detected (invitation fully used)."},"accountAddress":{"type":"string","description":"The on-chain account address created from this referral (if status is 'confirmed' or 'claimed'). Absent for 'pending' and 'stale' referrals."}},"required":["inviter","status"]}}}},"404":{"description":"Referral not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}},"410":{"description":"Referral already claimed - includes inviter info for display","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"inviter":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$"},"status":{"type":"string","enum":["claimed"]},"accountAddress":{"type":"string"}},"required":["error","inviter","status"]}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}}}}},"/referrals/my-referrals":{"get":{"tags":["Referrals"],"summary":"List my referrals","description":"Get all referral keys created by the authenticated user with their current status. Optionally specify an inviter address to query (e.g., a Safe address you own). Ownership is verified on-chain via Safe.getOwners(). Requires a valid session token from /auth/verify.","parameters":[{"schema":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"Query referrals for a different address (e.g. a Safe you own). Ownership is verified on-chain via Safe.getOwners() — returns 403 if you're not an owner. If omitted, returns referrals for the authenticated user's address (JWT `sub` claim)."},"required":false,"name":"inviter","in":"query"},{"schema":{"type":"string","description":"Filter by referral status. Comma-separated for multiple values. Valid values: pending, stale, confirmed, claimed. Omit to return all statuses.","example":"pending,confirmed"},"required":false,"name":"status","in":"query"},{"schema":{"type":"number","minimum":1,"maximum":500,"default":500,"description":"Max results per page (1-500, default 500)."},"required":false,"name":"limit","in":"query"},{"schema":{"type":"number","nullable":true,"minimum":0,"default":0,"description":"Number of results to skip for pagination (default 0)."},"required":false,"name":"offset","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Filter by distribution session membership. 'true' = only keys in at least one session. 'false' = only keys not in any session. Note: this is a post-fetch filter — `count` reflects filtered results but `total` reflects all referrals matching the status filter (before inSession filtering)."},"required":false,"name":"inSession","in":"query"},{"schema":{"type":"string","description":"JWT from the Circles Auth Service. Must have `aud=referrals-api` (set audience='referrals-api' in /challenge or /authenticate/options). Format: `Bearer <token>`. Get the token via SIWE (/challenge → /verify) or passkey auth at the auth service.","example":"Bearer eyJhbGciOiJSUzI1NiIs..."},"required":false,"name":"authorization","in":"header"}],"responses":{"200":{"description":"List of referrals","content":{"application/json":{"schema":{"type":"object","properties":{"referrals":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Internal database row ID (UUID)."},"privateKey":{"type":"string","pattern":"^0x[a-fA-F0-9]{64}$","description":"Full private key (only returned in authenticated /my-referrals endpoint). Use this to reconstruct the invitation link for resharing."},"status":{"type":"string","enum":["pending","stale","confirmed","claimed"],"description":"Referral lifecycle status: 'pending' = stored, waiting for on-chain activity. 'confirmed' = indexer detected AccountCreated event. 'stale' = no on-chain activity after 1h (still usable, reverts to pending on activity). 'claimed' = AccountClaimed event detected (invitation fully used)."},"accountAddress":{"type":"string","description":"On-chain account address created from this referral. Absent until the indexer detects an AccountCreated event."},"createdAt":{"type":"string","format":"date-time","description":"ISO 8601 timestamp when the referral was stored."},"pendingAt":{"type":"string","format":"date-time","description":"ISO 8601 timestamp when the referral entered 'pending' status."},"staleAt":{"type":"string","nullable":true,"format":"date-time","description":"ISO 8601 timestamp when marked stale. Null if never stale."},"confirmedAt":{"type":"string","nullable":true,"format":"date-time","description":"ISO 8601 timestamp when AccountCreated was detected. Null if not yet confirmed."},"claimedAt":{"type":"string","nullable":true,"format":"date-time","description":"ISO 8601 timestamp when AccountClaimed was detected. Null if not yet claimed."},"sessions":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Distribution session ID."},"slug":{"type":"string","description":"Distribution session slug."},"label":{"type":"string","nullable":true,"description":"Human-readable session label."}},"required":["id","slug","label"]},"description":"Distribution sessions this key belongs to. Empty array if not in any session."}},"required":["id","privateKey","status","createdAt","pendingAt","staleAt","confirmedAt","claimedAt","sessions"]}},"count":{"type":"number","description":"Number of results in this page."},"total":{"type":"number","description":"Total matching referrals across all pages (before pagination)."},"limit":{"type":"number","description":"Page size used for this request."},"offset":{"type":"number","description":"Number of results skipped."}},"required":["referrals","count","total","limit","offset"]}}}},"401":{"description":"Unauthorized - invalid or missing session token","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}},"403":{"description":"Forbidden - not authorized to view referrals for this address","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}},"503":{"description":"Authorization check temporarily unavailable - RPC error","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}}}}},"/referrals/list/{address}":{"get":{"tags":["Referrals"],"summary":"List referrals for an address (public)","description":"Get all referral keys for a given inviter address with truncated key previews. No authentication required. Private keys are masked (first 8 + last 4 chars visible). Supports pagination via limit/offset query params.","parameters":[{"schema":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"Inviter address to list referrals for","example":"0x1234567890123456789012345678901234567890"},"required":true,"name":"address","in":"path"},{"schema":{"type":"number","minimum":1,"maximum":200,"default":50,"description":"Maximum results per page (1-200, default 50)"},"required":false,"name":"limit","in":"query"},{"schema":{"type":"number","nullable":true,"minimum":0,"default":0,"description":"Number of results to skip (default 0)"},"required":false,"name":"offset","in":"query"},{"schema":{"type":"string","description":"Filter by status. Comma-separated for multiple. Omit to return all statuses.","example":"pending,confirmed,stale,claimed"},"required":false,"name":"status","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Filter by distribution session membership. 'true' = only keys in at least one session. 'false' = only keys not in any session. Note: this is a post-fetch filter — `count` reflects filtered results but `total` reflects all referrals matching the status filter (before inSession filtering)."},"required":false,"name":"inSession","in":"query"}],"responses":{"200":{"description":"List of referrals with masked private keys","content":{"application/json":{"schema":{"type":"object","properties":{"referrals":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Internal database row ID (UUID)."},"keyPreview":{"type":"string","description":"Truncated private key for identification only (first 8 + last 4 chars visible). The full key is never exposed in this endpoint.","example":"0x1a2b...7890"},"status":{"type":"string","enum":["pending","stale","confirmed","claimed"],"description":"Referral lifecycle status: 'pending' = stored, waiting for on-chain activity. 'confirmed' = indexer detected AccountCreated event. 'stale' = no on-chain activity after 1h (still usable, reverts to pending on activity). 'claimed' = AccountClaimed event detected (invitation fully used)."},"accountAddress":{"type":"string","nullable":true,"description":"On-chain account address created from this referral. Null until the indexer detects an AccountCreated event."},"createdAt":{"type":"string","format":"date-time","description":"ISO 8601 timestamp when the referral was stored."},"pendingAt":{"type":"string","format":"date-time","description":"ISO 8601 timestamp when the referral entered 'pending' status (same as createdAt)."},"staleAt":{"type":"string","nullable":true,"format":"date-time","description":"ISO 8601 timestamp when the referral was marked stale (1h without on-chain activity). Null if never stale."},"confirmedAt":{"type":"string","nullable":true,"format":"date-time","description":"ISO 8601 timestamp when AccountCreated was detected on-chain. Null if not yet confirmed."},"claimedAt":{"type":"string","nullable":true,"format":"date-time","description":"ISO 8601 timestamp when AccountClaimed was detected on-chain. Null if not yet claimed."},"inSession":{"type":"boolean","description":"Whether this key is also present in at least one distribution session."}},"required":["id","keyPreview","status","accountAddress","createdAt","pendingAt","staleAt","confirmedAt","claimedAt","inSession"]}},"count":{"type":"number","description":"Number of results in this page."},"total":{"type":"number","description":"Total referrals for this inviter (across all pages)."},"limit":{"type":"number","description":"Page size used for this request."},"offset":{"type":"number","description":"Number of results skipped."},"syncStatus":{"type":"string","enum":["synced","cached"],"description":"Whether on-chain status sync was performed for this request. 'synced' = fresh RPC check for up to 50 referrals on this page. 'cached' = returned DB-cached status (RPC cache TTL is 30s)."}},"required":["referrals","count","total","limit","offset","syncStatus"]}}}},"400":{"description":"Invalid address format","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}}}}},"/referrals/balance":{"get":{"tags":["Referrals"],"summary":"Check CRC balance","description":"Query the CRC balance for an address on Gnosis Chain. Returns both raw wei value and human-readable formatted value.","parameters":[{"schema":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"Ethereum address to check CRC balance for on Gnosis Chain. Works with any address (EOA or Safe). No authentication required.","example":"0x1234567890123456789012345678901234567890"},"required":true,"name":"address","in":"query"}],"responses":{"200":{"description":"Balance retrieved successfully","content":{"application/json":{"schema":{"type":"object","properties":{"address":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"The queried Ethereum address (same as the input)."},"balance":{"type":"string","description":"CRC token balance in wei (as string to prevent JavaScript precision loss for large numbers). Divide by 10^18 to get human-readable value.","example":"96000000000000000000"},"balanceFormatted":{"type":"string","description":"Human-readable CRC balance (balance / 10^18, rounded to 1 decimal).","example":"96.0"}},"required":["address","balance","balanceFormatted"]}}}},"400":{"description":"Invalid address format","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}},"503":{"description":"Balance lookup temporarily unavailable - RPC error","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}}}}},"/referrals/distribute/{address}":{"get":{"tags":["Referrals"],"summary":"Get next unclaimed invitation key (DEPRECATED)","deprecated":true,"description":"**DEPRECATED** — Use `GET /d/{slug}` instead, which provides session-scoped distribution with quota controls, expiry, and pause functionality.\n\nDispenses the next available invitation key from an inviter's pool. Designed for QR code / single-link distribution: community leads generate a batch of keys via /store-batch, then share one URL (this endpoint) as a QR code. Each visitor gets a unique key.\n\n**Concurrency safe** — uses PostgreSQL `FOR UPDATE SKIP LOCKED` so parallel requests each get a different key.\n\n**Content negotiation:**\n- Browser (`Accept: text/html`) + `DISTRIBUTION_BASE_URL` configured → 302 redirect to claim page\n- Otherwise → JSON with privateKey and optional claimUrl\n\n**Soft lock:** Distributed keys are soft-locked for 5 min (configurable). If unclaimed, they recycle back into the pool automatically.","parameters":[{"schema":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"Inviter address whose key pool to distribute from","example":"0x1234567890123456789012345678901234567890"},"required":true,"name":"address","in":"path"}],"responses":{"200":{"description":"Key dispensed (JSON response)","content":{"application/json":{"schema":{"type":"object","properties":{"privateKey":{"type":"string","pattern":"^0x[a-fA-F0-9]{64}$","description":"Full private key for the invitation link. Encode this into the referral URL."},"inviter":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"The inviter address that owns this key pool."},"claimUrl":{"type":"string","description":"Pre-built claim URL (only present when DISTRIBUTION_BASE_URL is configured). Redirect browsers here, or use privateKey to build your own URL."}},"required":["privateKey","inviter"]}}}},"302":{"description":"Redirect to claim page (browser request with DISTRIBUTION_BASE_URL configured)"},"404":{"description":"No unclaimed invitation keys available for this inviter","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}}}}},"/referrals/reassign":{"patch":{"tags":["Referrals"],"summary":"Bulk reassign referrals to a new inviter address","description":"Transfer ownership of referrals to a different address. Only non-claimed referrals owned by the authenticated user are reassigned. Claimed referrals and referrals not owned by the caller are silently skipped. Requires a valid session token.","parameters":[{"schema":{"type":"string","description":"JWT from the Circles Auth Service. Must have `aud=referrals-api` (set audience='referrals-api' in /challenge or /authenticate/options). Format: `Bearer <token>`. Get the token via SIWE (/challenge → /verify) or passkey auth at the auth service.","example":"Bearer eyJhbGciOiJSUzI1NiIs..."},"required":false,"name":"authorization","in":"header"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"referralIds":{"type":"array","items":{"type":"string","format":"uuid"},"minItems":1,"maxItems":500,"description":"UUIDs of referrals to reassign (max 500 per request)"},"newInviterAddress":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"Target Ethereum address to transfer referrals to","example":"0x1234567890123456789012345678901234567890"}},"required":["referralIds","newInviterAddress"]}}}},"responses":{"200":{"description":"Reassignment result","content":{"application/json":{"schema":{"type":"object","properties":{"reassigned":{"type":"number","description":"Number of referrals successfully reassigned"},"skippedClaimed":{"type":"number","description":"Number skipped because status is 'claimed' (terminal)"},"skippedNotOwned":{"type":"number","description":"Number skipped because they don't belong to the authenticated user or don't exist"}},"required":["reassigned","skippedClaimed","skippedNotOwned"]}}}},"400":{"description":"Invalid input","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}},"401":{"description":"Missing or invalid session token","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}}}}},"/referrals/distributions/sessions":{"post":{"tags":["Distributions"],"summary":"Create a distribution session","description":"Create a distribution session for an inviter. Returns a unique slug for QR codes / links. Auth required — body.inviterAddress must match authenticated address. The session owns its own key pool — add keys via POST /sessions/{id}/keys.","parameters":[{"schema":{"type":"string","description":"Bearer JWT token","example":"Bearer eyJ..."},"required":false,"name":"authorization","in":"header"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"inviterAddress":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"Inviter's Ethereum address","example":"0x1234567890123456789012345678901234567890"},"label":{"type":"string","maxLength":200,"description":"Human-readable label (e.g. 'ETHDenver 2026 booth')","example":"ETHDenver 2026 booth"},"expiresAt":{"type":"string","format":"date-time","description":"ISO 8601 expiry timestamp (optional)","example":"2026-03-01T00:00:00.000Z"},"metadata":{"type":"object","additionalProperties":{"nullable":true},"description":"Public metadata (returned on public endpoints). Max 4KB JSON object."},"privateMetadata":{"type":"object","additionalProperties":{"nullable":true},"description":"Private metadata (only returned on authenticated endpoints). Max 4KB JSON object."}},"required":["inviterAddress"]}}}},"responses":{"201":{"description":"Session created","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"inviterAddress":{"type":"string"},"label":{"type":"string","nullable":true},"expiresAt":{"type":"string","nullable":true},"paused":{"type":"boolean"},"queuedCount":{"type":"number"},"dispatchedCount":{"type":"number"},"claimedCount":{"type":"number"},"distributionUrl":{"type":"string","nullable":true,"description":"Full URL for QR codes (only when DISTRIBUTION_BASE_URL is configured)"},"metadata":{"type":"object","nullable":true,"additionalProperties":{"nullable":true},"description":"Public metadata (returned on public endpoints)"},"privateMetadata":{"type":"object","nullable":true,"additionalProperties":{"nullable":true},"description":"Private metadata (only returned on authenticated endpoints)"},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","slug","inviterAddress","label","expiresAt","paused","queuedCount","dispatchedCount","claimedCount","distributionUrl","metadata","privateMetadata","createdAt","updatedAt"]}}}},"400":{"description":"Invalid input","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"401":{"description":"Missing or invalid token","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"403":{"description":"Authenticated address does not match inviterAddress","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}}}},"get":{"tags":["Distributions"],"summary":"List distribution sessions","description":"List all distribution sessions for an inviter address, with pagination. Auth required — inviter query param must match authenticated address.","parameters":[{"schema":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"Filter by inviter address","example":"0x1234567890123456789012345678901234567890"},"required":true,"name":"inviter","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"default":20,"description":"Page size (1-100, default 20)"},"required":false,"name":"limit","in":"query"},{"schema":{"type":"integer","nullable":true,"minimum":0,"default":0,"description":"Pagination offset (default 0)"},"required":false,"name":"offset","in":"query"},{"schema":{"type":"string","description":"Bearer JWT token","example":"Bearer eyJ..."},"required":false,"name":"authorization","in":"header"}],"responses":{"200":{"description":"Paginated list of sessions","content":{"application/json":{"schema":{"type":"object","properties":{"sessions":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"inviterAddress":{"type":"string"},"label":{"type":"string","nullable":true},"expiresAt":{"type":"string","nullable":true},"paused":{"type":"boolean"},"queuedCount":{"type":"number"},"dispatchedCount":{"type":"number"},"claimedCount":{"type":"number"},"distributionUrl":{"type":"string","nullable":true,"description":"Full URL for QR codes (only when DISTRIBUTION_BASE_URL is configured)"},"metadata":{"type":"object","nullable":true,"additionalProperties":{"nullable":true},"description":"Public metadata (returned on public endpoints)"},"privateMetadata":{"type":"object","nullable":true,"additionalProperties":{"nullable":true},"description":"Private metadata (only returned on authenticated endpoints)"},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","slug","inviterAddress","label","expiresAt","paused","queuedCount","dispatchedCount","claimedCount","distributionUrl","metadata","privateMetadata","createdAt","updatedAt"]}},"total":{"type":"number"},"limit":{"type":"number"},"offset":{"type":"number"}},"required":["sessions","total","limit","offset"]}}}},"400":{"description":"Invalid query params","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"401":{"description":"Missing or invalid token","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"403":{"description":"Authenticated address does not match inviterAddress","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}}}}},"/referrals/distributions/sessions/{id}":{"get":{"tags":["Distributions"],"summary":"Get a distribution session","description":"Auth required — authenticated address must match inviterAddress.","parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","description":"Bearer JWT token","example":"Bearer eyJ..."},"required":false,"name":"authorization","in":"header"}],"responses":{"200":{"description":"Session details","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"inviterAddress":{"type":"string"},"label":{"type":"string","nullable":true},"expiresAt":{"type":"string","nullable":true},"paused":{"type":"boolean"},"queuedCount":{"type":"number"},"dispatchedCount":{"type":"number"},"claimedCount":{"type":"number"},"distributionUrl":{"type":"string","nullable":true,"description":"Full URL for QR codes (only when DISTRIBUTION_BASE_URL is configured)"},"metadata":{"type":"object","nullable":true,"additionalProperties":{"nullable":true},"description":"Public metadata (returned on public endpoints)"},"privateMetadata":{"type":"object","nullable":true,"additionalProperties":{"nullable":true},"description":"Private metadata (only returned on authenticated endpoints)"},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","slug","inviterAddress","label","expiresAt","paused","queuedCount","dispatchedCount","claimedCount","distributionUrl","metadata","privateMetadata","createdAt","updatedAt"]}}}},"401":{"description":"Missing or invalid token","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"403":{"description":"Authenticated address does not match inviterAddress","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"404":{"description":"Session not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}}}},"patch":{"tags":["Distributions"],"summary":"Update a distribution session","description":"Update session properties. Auth required — authenticated address must match inviterAddress.","parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","description":"Bearer JWT token","example":"Bearer eyJ..."},"required":false,"name":"authorization","in":"header"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"label":{"type":"string","maxLength":200},"expiresAt":{"type":"string","nullable":true,"format":"date-time","description":"New expiry (ISO 8601), or null to remove expiry"},"paused":{"type":"boolean"},"inviterAddress":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"Transfer ownership to a new Ethereum address. Only the current owner can initiate."},"metadata":{"type":"object","nullable":true,"additionalProperties":{"nullable":true},"description":"Set public metadata, or null to clear"},"privateMetadata":{"type":"object","nullable":true,"additionalProperties":{"nullable":true},"description":"Set private metadata, or null to clear"}}}}}},"responses":{"200":{"description":"Session updated","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"inviterAddress":{"type":"string"},"label":{"type":"string","nullable":true},"expiresAt":{"type":"string","nullable":true},"paused":{"type":"boolean"},"queuedCount":{"type":"number"},"dispatchedCount":{"type":"number"},"claimedCount":{"type":"number"},"distributionUrl":{"type":"string","nullable":true,"description":"Full URL for QR codes (only when DISTRIBUTION_BASE_URL is configured)"},"metadata":{"type":"object","nullable":true,"additionalProperties":{"nullable":true},"description":"Public metadata (returned on public endpoints)"},"privateMetadata":{"type":"object","nullable":true,"additionalProperties":{"nullable":true},"description":"Private metadata (only returned on authenticated endpoints)"},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","slug","inviterAddress","label","expiresAt","paused","queuedCount","dispatchedCount","claimedCount","distributionUrl","metadata","privateMetadata","createdAt","updatedAt"]}}}},"400":{"description":"Invalid update","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"401":{"description":"Missing or invalid token","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"403":{"description":"Authenticated address does not match inviterAddress","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"404":{"description":"Session not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}}}},"delete":{"tags":["Distributions"],"summary":"Delete a distribution session","description":"Delete a session. Rejected if any keys have status='dispatched' (in-flight). Auth required — authenticated address must match inviterAddress.","parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","description":"Bearer JWT token","example":"Bearer eyJ..."},"required":false,"name":"authorization","in":"header"}],"responses":{"200":{"description":"Session deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}},"required":["success"]}}}},"400":{"description":"Cannot delete session with dispatched keys","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"dispatchedCount":{"type":"number"}},"required":["error"]}}}},"401":{"description":"Missing or invalid token","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"403":{"description":"Authenticated address does not match inviterAddress","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"404":{"description":"Session not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}}}}},"/referrals/distributions/sessions/{id}/keys":{"post":{"tags":["Distributions"],"summary":"Add keys to a distribution session","description":"Add one or more private keys to a session's pool. Addresses are derived at insert time and on-chain status is batch-checked via multicall. Already-claimed keys are stored as 'claimed' (audit trail). Duplicate keys within the same session are silently ignored. Auth required — authenticated address must match inviterAddress.","parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","description":"Bearer JWT token","example":"Bearer eyJ..."},"required":false,"name":"authorization","in":"header"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"keys":{"type":"array","items":{"type":"string","pattern":"^0x[a-fA-F0-9]{64}$"},"minItems":1,"maxItems":100,"description":"Array of raw private keys (max 100 per request)"}},"required":["keys"]}}}},"responses":{"200":{"description":"Keys processed","content":{"application/json":{"schema":{"type":"object","properties":{"added":{"type":"number"},"skipped":{"type":"number"},"claimed":{"type":"number"},"confirmed":{"type":"number","description":"Keys already confirmed on-chain (AccountCreated exists)"},"errors":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string"},"error":{"type":"string"}},"required":["key","error"]}}},"required":["added","skipped","claimed","confirmed","errors"]}}}},"401":{"description":"Missing or invalid token","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"403":{"description":"Authenticated address does not match inviterAddress","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"404":{"description":"Session not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}}}},"get":{"tags":["Distributions"],"summary":"List keys in a distribution session","description":"Returns the full key list for a session with pagination. Auth required — authenticated address must match inviterAddress.","parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","description":"Filter by distribution status. Comma-separated for multiple: queued,dispatched","example":"queued"},"required":false,"name":"status","in":"query"},{"schema":{"type":"string","description":"Filter by on-chain lifecycle status. Comma-separated for multiple: stale,confirmed","example":"stale,confirmed"},"required":false,"name":"onchainStatus","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":200,"default":50,"description":"Page size (1-200, default 50)"},"required":false,"name":"limit","in":"query"},{"schema":{"type":"integer","nullable":true,"minimum":0,"default":0,"description":"Pagination offset"},"required":false,"name":"offset","in":"query"},{"schema":{"type":"string","description":"Bearer JWT token","example":"Bearer eyJ..."},"required":false,"name":"authorization","in":"header"}],"responses":{"200":{"description":"Key list","content":{"application/json":{"schema":{"type":"object","properties":{"keys":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"privateKey":{"type":"string","description":"Full private key"},"signerAddress":{"type":"string","nullable":true},"accountAddress":{"type":"string","nullable":true},"status":{"type":"string","enum":["queued","dispatched","claimed"]},"onchainStatus":{"type":"string","enum":["pending","confirmed","stale","claimed"]},"dispatchedAt":{"type":"string","nullable":true},"confirmedAt":{"type":"string","nullable":true},"staleAt":{"type":"string","nullable":true},"claimedAt":{"type":"string","nullable":true},"claimedBySource":{"type":"string","nullable":true},"addedAt":{"type":"string"}},"required":["id","privateKey","signerAddress","accountAddress","status","onchainStatus","dispatchedAt","confirmedAt","staleAt","claimedAt","claimedBySource","addedAt"]}},"total":{"type":"number"},"queuedCount":{"type":"number"},"dispatchedCount":{"type":"number"},"claimedCount":{"type":"number"},"limit":{"type":"number"},"offset":{"type":"number"}},"required":["keys","total","queuedCount","dispatchedCount","claimedCount","limit","offset"]}}}},"401":{"description":"Missing or invalid token","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"403":{"description":"Authenticated address does not match inviterAddress","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"404":{"description":"Session not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}}}}},"/referrals/distributions/sessions/{id}/keys/{keyId}":{"delete":{"tags":["Distributions"],"summary":"Remove a key from a distribution session","description":"Remove a single key by ID. Rejected if key has status='dispatched'. Auth required — authenticated address must match inviterAddress.","parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","format":"uuid"},"required":true,"name":"keyId","in":"path"},{"schema":{"type":"string","description":"Bearer JWT token","example":"Bearer eyJ..."},"required":false,"name":"authorization","in":"header"}],"responses":{"200":{"description":"Key removed","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}},"required":["success"]}}}},"400":{"description":"Key is currently dispatched","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"401":{"description":"Missing or invalid token","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"403":{"description":"Authenticated address does not match inviterAddress","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"404":{"description":"Session or key not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}}}}},"/referrals/d/{slug}":{"get":{"tags":["Distributions"],"summary":"Dispense a key via distribution session","description":"Dispense the next available invitation key from a distribution session's per-session pool. PRIMARY PATH: tries the queued pool first (no RPC call). FALLBACK PATH: if queued is empty, walks dispatched keys older than 5 min, checks on-chain status, recycles unclaimed ones.\n\n**Content negotiation:**\n- Browser (`Accept: text/html`) + `DISTRIBUTION_BASE_URL` → 302 redirect\n- Otherwise → JSON with privateKey, inviter, and optional claimUrl\n\n**Error codes:**\n- 404: Session not found\n- 410: Session expired or no keys available\n- 423: Session paused","parameters":[{"schema":{"type":"string","minLength":4,"maxLength":32,"pattern":"^[0-9A-Za-z]+$","description":"Distribution session slug (from QR code / link)","example":"aB3xK9mZ"},"required":true,"name":"slug","in":"path"}],"responses":{"200":{"description":"Key dispensed (JSON)","content":{"application/json":{"schema":{"type":"object","properties":{"privateKey":{"type":"string","pattern":"^0x[a-fA-F0-9]{64}$","description":"Full private key for the invitation link"},"inviter":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"Inviter address that owns this session"},"claimUrl":{"type":"string","description":"Pre-built claim URL (when DISTRIBUTION_BASE_URL configured)"},"sessionSlug":{"type":"string","description":"The session slug this key was dispensed through"},"metadata":{"type":"object","nullable":true,"additionalProperties":{"nullable":true},"description":"Public session metadata (e.g. group address for auto-trust, query params)"}},"required":["privateKey","inviter","sessionSlug","metadata"]}}}},"302":{"description":"Redirect to claim page (browser request)"},"404":{"description":"Session not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"410":{"description":"Session expired or no keys available","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"423":{"description":"Session paused","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}}}}},"/referrals/d/{slug}/stats":{"get":{"tags":["Distributions"],"summary":"Get distribution session stats (public)","description":"Returns aggregate key counts for a distribution session. No authentication required — the slug acts as a capability token. Response contains only counts, no sensitive data.","parameters":[{"schema":{"type":"string","minLength":4,"maxLength":32,"pattern":"^[0-9A-Za-z]+$","description":"Distribution session slug","example":"aB3xK9mZ"},"required":true,"name":"slug","in":"path"}],"responses":{"200":{"description":"Session stats","content":{"application/json":{"schema":{"type":"object","properties":{"queued":{"type":"number","description":"Keys waiting to be dispensed"},"dispatched":{"type":"number","description":"Keys dispensed, awaiting claim"},"claimed":{"type":"number","description":"Keys successfully claimed"},"total":{"type":"number","description":"Total keys in session"},"onchainPending":{"type":"number","description":"Keys with no on-chain activity yet"},"onchainConfirmed":{"type":"number","description":"Keys with AccountCreated event (Safe deployed)"},"onchainStale":{"type":"number","description":"Confirmed >1h without claim"},"onchainClaimed":{"type":"number","description":"Keys with AccountClaimed event (terminal)"},"metadata":{"type":"object","nullable":true,"additionalProperties":{"nullable":true},"description":"Public session metadata"}},"required":["queued","dispatched","claimed","total","onchainPending","onchainConfirmed","onchainStale","onchainClaimed","metadata"]}}}},"404":{"description":"Session not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}}}}},"/health":{"get":{"tags":["Health"],"summary":"Basic health check","description":"Check if the service is running. For Kubernetes, prefer /live and /ready.","responses":{"200":{"description":"Service is healthy","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"timestamp":{"type":"string","format":"date-time"}},"required":["status","timestamp"]}}}}}}},"/health/live":{"get":{"tags":["Health"],"summary":"Liveness probe","description":"Kubernetes liveness probe. Returns 200 if the process is alive. If this fails, Kubernetes should restart the container.","responses":{"200":{"description":"Process is alive","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok","error"]},"timestamp":{"type":"string","format":"date-time"}},"required":["status","timestamp"]}}}}}}},"/health/ready":{"get":{"tags":["Health"],"summary":"Readiness probe","description":"Kubernetes readiness probe. Returns 200 if the service can serve traffic. Checks database connectivity. If this fails, Kubernetes should stop routing traffic.","responses":{"200":{"description":"Service is ready to accept traffic","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok","degraded","error"]},"timestamp":{"type":"string","format":"date-time"},"checks":{"type":"object","properties":{"database":{"type":"object","properties":{"status":{"type":"string","enum":["ok","error"]},"latencyMs":{"type":"number"},"error":{"type":"string"},"pool":{"type":"object","properties":{"max":{"type":"number"},"idleTimeoutSeconds":{"type":"number","nullable":true}},"required":["max","idleTimeoutSeconds"]}},"required":["status"]},"referralsModule":{"type":"object","properties":{"status":{"type":"string","enum":["ok","warning","skipped"]},"configured":{"type":"boolean"},"address":{"type":"string"},"skipValidation":{"type":"boolean"},"warning":{"type":"string"}},"required":["status","configured","skipValidation"]},"authService":{"type":"object","properties":{"status":{"type":"string","enum":["ok","error"]},"latencyMs":{"type":"number"},"error":{"type":"string"}},"required":["status"]},"indexer":{"type":"object","properties":{"status":{"type":"string","enum":["ok","degraded","down"]},"websocket":{"type":"object","properties":{"connected":{"type":"boolean"},"readyState":{"type":"string"},"subscriptionId":{"type":"string","nullable":true},"reconnectAttempts":{"type":"number"},"maintenanceMode":{"type":"boolean"},"shuttingDown":{"type":"boolean"},"lastMessageAgeMs":{"type":"number","nullable":true},"lastEventAgeMs":{"type":"number","nullable":true}},"required":["connected","readyState","subscriptionId","reconnectAttempts","maintenanceMode","shuttingDown","lastMessageAgeMs","lastEventAgeMs"]},"polling":{"type":"object","properties":{"active":{"type":"boolean"},"intervalMs":{"type":"number"}},"required":["active","intervalMs"]}},"required":["status","websocket","polling"]}},"required":["database","authService"]}},"required":["status","timestamp","checks"]}}}},"503":{"description":"Service is not ready","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok","degraded","error"]},"timestamp":{"type":"string","format":"date-time"},"checks":{"type":"object","properties":{"database":{"type":"object","properties":{"status":{"type":"string","enum":["ok","error"]},"latencyMs":{"type":"number"},"error":{"type":"string"},"pool":{"type":"object","properties":{"max":{"type":"number"},"idleTimeoutSeconds":{"type":"number","nullable":true}},"required":["max","idleTimeoutSeconds"]}},"required":["status"]},"referralsModule":{"type":"object","properties":{"status":{"type":"string","enum":["ok","warning","skipped"]},"configured":{"type":"boolean"},"address":{"type":"string"},"skipValidation":{"type":"boolean"},"warning":{"type":"string"}},"required":["status","configured","skipValidation"]},"authService":{"type":"object","properties":{"status":{"type":"string","enum":["ok","error"]},"latencyMs":{"type":"number"},"error":{"type":"string"}},"required":["status"]},"indexer":{"type":"object","properties":{"status":{"type":"string","enum":["ok","degraded","down"]},"websocket":{"type":"object","properties":{"connected":{"type":"boolean"},"readyState":{"type":"string"},"subscriptionId":{"type":"string","nullable":true},"reconnectAttempts":{"type":"number"},"maintenanceMode":{"type":"boolean"},"shuttingDown":{"type":"boolean"},"lastMessageAgeMs":{"type":"number","nullable":true},"lastEventAgeMs":{"type":"number","nullable":true}},"required":["connected","readyState","subscriptionId","reconnectAttempts","maintenanceMode","shuttingDown","lastMessageAgeMs","lastEventAgeMs"]},"polling":{"type":"object","properties":{"active":{"type":"boolean"},"intervalMs":{"type":"number"}},"required":["active","intervalMs"]}},"required":["status","websocket","polling"]}},"required":["database","authService"]}},"required":["status","timestamp","checks"]}}}}}}},"/store":{"post":{"tags":["Referrals"],"summary":"Store a referral mapping","description":"Store a mapping from inviter address to private key. Called by SDK when generating referral links. Only format validation is performed (64 hex chars). On-chain account existence is verified at claim time via indexer events. The inviter address is self-declared for dashboard visibility only - the on-chain indexer captures the true inviter from contract events.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"privateKey":{"type":"string","pattern":"^0x[a-fA-F0-9]{64}$","description":"Hex-encoded secp256k1 private key (64 hex chars, 0x-prefixed). The SDK generates this keypair locally — the private key becomes the invitation link, and the derived public address is used to track the referral on-chain. WARNING: This is stored in the database. Do NOT reuse keys from real wallets.","example":"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"},"inviter":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"The inviter's Ethereum address (EOA or Safe). Displayed in the dashboard under 'my referrals'. This is self-declared — the on-chain indexer captures the true inviter from contract events at claim time, so spoofing this only affects dashboard display.","example":"0x1234567890123456789012345678901234567890"}},"required":["privateKey","inviter"],"example":{"privateKey":"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef","inviter":"0x1234567890123456789012345678901234567890"}}}}},"responses":{"200":{"description":"Referral stored successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}},"required":["success"]}}}},"400":{"description":"Bad request - invalid private key format","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}}}}},"/store-batch":{"post":{"tags":["Referrals"],"summary":"Store multiple referrals in batch","description":"Store multiple invitation mappings in a single request. More efficient than multiple individual /store calls. Only format validation is performed. Returns success if at least one invitation was stored successfully.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"invitations":{"type":"array","items":{"type":"object","properties":{"privateKey":{"type":"string","pattern":"^0x[a-fA-F0-9]{64}$","description":"Hex-encoded secp256k1 private key (64 hex chars, 0x-prefixed). The SDK generates this keypair locally — the private key becomes the invitation link, and the derived public address is used to track the referral on-chain. WARNING: This is stored in the database. Do NOT reuse keys from real wallets.","example":"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"},"inviter":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"The inviter's Ethereum address (EOA or Safe). Displayed in the dashboard under 'my referrals'. This is self-declared — the on-chain indexer captures the true inviter from contract events at claim time, so spoofing this only affects dashboard display.","example":"0x1234567890123456789012345678901234567890"}},"required":["privateKey","inviter"]},"minItems":1,"maxItems":200,"description":"Array of 1-200 invitations to store in a single request. Each invitation has the same format as the /store endpoint. Processing is independent — one failure doesn't block others."}},"required":["invitations"],"example":{"invitations":[{"privateKey":"0x1111111111111111111111111111111111111111111111111111111111111111","inviter":"0x1234567890123456789012345678901234567890"},{"privateKey":"0x2222222222222222222222222222222222222222222222222222222222222222","inviter":"0x1234567890123456789012345678901234567890"}]}}}}},"responses":{"200":{"description":"Batch processing complete (some or all invitations stored)","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","description":"true if at least one invitation was stored. false only if ALL failed (returns 400)."},"stored":{"type":"number","description":"Number of invitations successfully stored in the database."},"failed":{"type":"number","description":"Number of invitations that failed validation or storage (see `errors` array for details)."},"errors":{"type":"array","items":{"type":"object","properties":{"index":{"type":"number","description":"Zero-based index of the failed invitation in the request array."},"keyPreview":{"type":"string","description":"Masked private key for identification (first 10 + last 4 chars). Never exposes the full key.","example":"0x1234567...cdef"},"reason":{"type":"string","description":"Why this invitation failed (e.g. 'Duplicate key', 'Rate limit exceeded')."}},"required":["index","keyPreview","reason"]},"description":"Details of failed invitations. Only present if `failed > 0`."}},"required":["success","stored","failed"]}}}},"400":{"description":"Bad request - invalid input or all invitations failed","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}}}}},"/retrieve":{"get":{"tags":["Referrals"],"summary":"Retrieve inviter by private key","description":"Get the inviter address and status for a given private key. Public endpoint for referral link flow.","parameters":[{"schema":{"type":"string","pattern":"^0x[a-fA-F0-9]{64}$","description":"The private key from the invitation link. The frontend extracts this from the URL and sends it here to look up who invited the user. Returns the inviter address and referral status. Returns 410 if already claimed.","example":"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"},"required":true,"name":"key","in":"query"}],"responses":{"200":{"description":"Referral found","content":{"application/json":{"schema":{"type":"object","properties":{"inviter":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"The inviter's Ethereum address who created this referral."},"status":{"type":"string","enum":["pending","stale","confirmed","claimed"],"description":"Referral lifecycle status: 'pending' = stored, waiting for on-chain activity. 'confirmed' = indexer detected AccountCreated event. 'stale' = no on-chain activity after 1h (still usable, reverts to pending on activity). 'claimed' = AccountClaimed event detected (invitation fully used)."},"accountAddress":{"type":"string","description":"The on-chain account address created from this referral (if status is 'confirmed' or 'claimed'). Absent for 'pending' and 'stale' referrals."}},"required":["inviter","status"]}}}},"404":{"description":"Referral not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}},"410":{"description":"Referral already claimed - includes inviter info for display","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"inviter":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$"},"status":{"type":"string","enum":["claimed"]},"accountAddress":{"type":"string"}},"required":["error","inviter","status"]}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}}}}},"/my-referrals":{"get":{"tags":["Referrals"],"summary":"List my referrals","description":"Get all referral keys created by the authenticated user with their current status. Optionally specify an inviter address to query (e.g., a Safe address you own). Ownership is verified on-chain via Safe.getOwners(). Requires a valid session token from /auth/verify.","parameters":[{"schema":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"Query referrals for a different address (e.g. a Safe you own). Ownership is verified on-chain via Safe.getOwners() — returns 403 if you're not an owner. If omitted, returns referrals for the authenticated user's address (JWT `sub` claim)."},"required":false,"name":"inviter","in":"query"},{"schema":{"type":"string","description":"Filter by referral status. Comma-separated for multiple values. Valid values: pending, stale, confirmed, claimed. Omit to return all statuses.","example":"pending,confirmed"},"required":false,"name":"status","in":"query"},{"schema":{"type":"number","minimum":1,"maximum":500,"default":500,"description":"Max results per page (1-500, default 500)."},"required":false,"name":"limit","in":"query"},{"schema":{"type":"number","nullable":true,"minimum":0,"default":0,"description":"Number of results to skip for pagination (default 0)."},"required":false,"name":"offset","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Filter by distribution session membership. 'true' = only keys in at least one session. 'false' = only keys not in any session. Note: this is a post-fetch filter — `count` reflects filtered results but `total` reflects all referrals matching the status filter (before inSession filtering)."},"required":false,"name":"inSession","in":"query"},{"schema":{"type":"string","description":"JWT from the Circles Auth Service. Must have `aud=referrals-api` (set audience='referrals-api' in /challenge or /authenticate/options). Format: `Bearer <token>`. Get the token via SIWE (/challenge → /verify) or passkey auth at the auth service.","example":"Bearer eyJhbGciOiJSUzI1NiIs..."},"required":false,"name":"authorization","in":"header"}],"responses":{"200":{"description":"List of referrals","content":{"application/json":{"schema":{"type":"object","properties":{"referrals":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Internal database row ID (UUID)."},"privateKey":{"type":"string","pattern":"^0x[a-fA-F0-9]{64}$","description":"Full private key (only returned in authenticated /my-referrals endpoint). Use this to reconstruct the invitation link for resharing."},"status":{"type":"string","enum":["pending","stale","confirmed","claimed"],"description":"Referral lifecycle status: 'pending' = stored, waiting for on-chain activity. 'confirmed' = indexer detected AccountCreated event. 'stale' = no on-chain activity after 1h (still usable, reverts to pending on activity). 'claimed' = AccountClaimed event detected (invitation fully used)."},"accountAddress":{"type":"string","description":"On-chain account address created from this referral. Absent until the indexer detects an AccountCreated event."},"createdAt":{"type":"string","format":"date-time","description":"ISO 8601 timestamp when the referral was stored."},"pendingAt":{"type":"string","format":"date-time","description":"ISO 8601 timestamp when the referral entered 'pending' status."},"staleAt":{"type":"string","nullable":true,"format":"date-time","description":"ISO 8601 timestamp when marked stale. Null if never stale."},"confirmedAt":{"type":"string","nullable":true,"format":"date-time","description":"ISO 8601 timestamp when AccountCreated was detected. Null if not yet confirmed."},"claimedAt":{"type":"string","nullable":true,"format":"date-time","description":"ISO 8601 timestamp when AccountClaimed was detected. Null if not yet claimed."},"sessions":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Distribution session ID."},"slug":{"type":"string","description":"Distribution session slug."},"label":{"type":"string","nullable":true,"description":"Human-readable session label."}},"required":["id","slug","label"]},"description":"Distribution sessions this key belongs to. Empty array if not in any session."}},"required":["id","privateKey","status","createdAt","pendingAt","staleAt","confirmedAt","claimedAt","sessions"]}},"count":{"type":"number","description":"Number of results in this page."},"total":{"type":"number","description":"Total matching referrals across all pages (before pagination)."},"limit":{"type":"number","description":"Page size used for this request."},"offset":{"type":"number","description":"Number of results skipped."}},"required":["referrals","count","total","limit","offset"]}}}},"401":{"description":"Unauthorized - invalid or missing session token","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}},"403":{"description":"Forbidden - not authorized to view referrals for this address","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}},"503":{"description":"Authorization check temporarily unavailable - RPC error","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}}}}},"/list/{address}":{"get":{"tags":["Referrals"],"summary":"List referrals for an address (public)","description":"Get all referral keys for a given inviter address with truncated key previews. No authentication required. Private keys are masked (first 8 + last 4 chars visible). Supports pagination via limit/offset query params.","parameters":[{"schema":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"Inviter address to list referrals for","example":"0x1234567890123456789012345678901234567890"},"required":true,"name":"address","in":"path"},{"schema":{"type":"number","minimum":1,"maximum":200,"default":50,"description":"Maximum results per page (1-200, default 50)"},"required":false,"name":"limit","in":"query"},{"schema":{"type":"number","nullable":true,"minimum":0,"default":0,"description":"Number of results to skip (default 0)"},"required":false,"name":"offset","in":"query"},{"schema":{"type":"string","description":"Filter by status. Comma-separated for multiple. Omit to return all statuses.","example":"pending,confirmed,stale,claimed"},"required":false,"name":"status","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Filter by distribution session membership. 'true' = only keys in at least one session. 'false' = only keys not in any session. Note: this is a post-fetch filter — `count` reflects filtered results but `total` reflects all referrals matching the status filter (before inSession filtering)."},"required":false,"name":"inSession","in":"query"}],"responses":{"200":{"description":"List of referrals with masked private keys","content":{"application/json":{"schema":{"type":"object","properties":{"referrals":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Internal database row ID (UUID)."},"keyPreview":{"type":"string","description":"Truncated private key for identification only (first 8 + last 4 chars visible). The full key is never exposed in this endpoint.","example":"0x1a2b...7890"},"status":{"type":"string","enum":["pending","stale","confirmed","claimed"],"description":"Referral lifecycle status: 'pending' = stored, waiting for on-chain activity. 'confirmed' = indexer detected AccountCreated event. 'stale' = no on-chain activity after 1h (still usable, reverts to pending on activity). 'claimed' = AccountClaimed event detected (invitation fully used)."},"accountAddress":{"type":"string","nullable":true,"description":"On-chain account address created from this referral. Null until the indexer detects an AccountCreated event."},"createdAt":{"type":"string","format":"date-time","description":"ISO 8601 timestamp when the referral was stored."},"pendingAt":{"type":"string","format":"date-time","description":"ISO 8601 timestamp when the referral entered 'pending' status (same as createdAt)."},"staleAt":{"type":"string","nullable":true,"format":"date-time","description":"ISO 8601 timestamp when the referral was marked stale (1h without on-chain activity). Null if never stale."},"confirmedAt":{"type":"string","nullable":true,"format":"date-time","description":"ISO 8601 timestamp when AccountCreated was detected on-chain. Null if not yet confirmed."},"claimedAt":{"type":"string","nullable":true,"format":"date-time","description":"ISO 8601 timestamp when AccountClaimed was detected on-chain. Null if not yet claimed."},"inSession":{"type":"boolean","description":"Whether this key is also present in at least one distribution session."}},"required":["id","keyPreview","status","accountAddress","createdAt","pendingAt","staleAt","confirmedAt","claimedAt","inSession"]}},"count":{"type":"number","description":"Number of results in this page."},"total":{"type":"number","description":"Total referrals for this inviter (across all pages)."},"limit":{"type":"number","description":"Page size used for this request."},"offset":{"type":"number","description":"Number of results skipped."},"syncStatus":{"type":"string","enum":["synced","cached"],"description":"Whether on-chain status sync was performed for this request. 'synced' = fresh RPC check for up to 50 referrals on this page. 'cached' = returned DB-cached status (RPC cache TTL is 30s)."}},"required":["referrals","count","total","limit","offset","syncStatus"]}}}},"400":{"description":"Invalid address format","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}}}}},"/balance":{"get":{"tags":["Referrals"],"summary":"Check CRC balance","description":"Query the CRC balance for an address on Gnosis Chain. Returns both raw wei value and human-readable formatted value.","parameters":[{"schema":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"Ethereum address to check CRC balance for on Gnosis Chain. Works with any address (EOA or Safe). No authentication required.","example":"0x1234567890123456789012345678901234567890"},"required":true,"name":"address","in":"query"}],"responses":{"200":{"description":"Balance retrieved successfully","content":{"application/json":{"schema":{"type":"object","properties":{"address":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"The queried Ethereum address (same as the input)."},"balance":{"type":"string","description":"CRC token balance in wei (as string to prevent JavaScript precision loss for large numbers). Divide by 10^18 to get human-readable value.","example":"96000000000000000000"},"balanceFormatted":{"type":"string","description":"Human-readable CRC balance (balance / 10^18, rounded to 1 decimal).","example":"96.0"}},"required":["address","balance","balanceFormatted"]}}}},"400":{"description":"Invalid address format","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}},"503":{"description":"Balance lookup temporarily unavailable - RPC error","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}}}}},"/distribute/{address}":{"get":{"tags":["Referrals"],"summary":"Get next unclaimed invitation key (DEPRECATED)","deprecated":true,"description":"**DEPRECATED** — Use `GET /d/{slug}` instead, which provides session-scoped distribution with quota controls, expiry, and pause functionality.\n\nDispenses the next available invitation key from an inviter's pool. Designed for QR code / single-link distribution: community leads generate a batch of keys via /store-batch, then share one URL (this endpoint) as a QR code. Each visitor gets a unique key.\n\n**Concurrency safe** — uses PostgreSQL `FOR UPDATE SKIP LOCKED` so parallel requests each get a different key.\n\n**Content negotiation:**\n- Browser (`Accept: text/html`) + `DISTRIBUTION_BASE_URL` configured → 302 redirect to claim page\n- Otherwise → JSON with privateKey and optional claimUrl\n\n**Soft lock:** Distributed keys are soft-locked for 5 min (configurable). If unclaimed, they recycle back into the pool automatically.","parameters":[{"schema":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"Inviter address whose key pool to distribute from","example":"0x1234567890123456789012345678901234567890"},"required":true,"name":"address","in":"path"}],"responses":{"200":{"description":"Key dispensed (JSON response)","content":{"application/json":{"schema":{"type":"object","properties":{"privateKey":{"type":"string","pattern":"^0x[a-fA-F0-9]{64}$","description":"Full private key for the invitation link. Encode this into the referral URL."},"inviter":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"The inviter address that owns this key pool."},"claimUrl":{"type":"string","description":"Pre-built claim URL (only present when DISTRIBUTION_BASE_URL is configured). Redirect browsers here, or use privateKey to build your own URL."}},"required":["privateKey","inviter"]}}}},"302":{"description":"Redirect to claim page (browser request with DISTRIBUTION_BASE_URL configured)"},"404":{"description":"No unclaimed invitation keys available for this inviter","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}}}}},"/reassign":{"patch":{"tags":["Referrals"],"summary":"Bulk reassign referrals to a new inviter address","description":"Transfer ownership of referrals to a different address. Only non-claimed referrals owned by the authenticated user are reassigned. Claimed referrals and referrals not owned by the caller are silently skipped. Requires a valid session token.","parameters":[{"schema":{"type":"string","description":"JWT from the Circles Auth Service. Must have `aud=referrals-api` (set audience='referrals-api' in /challenge or /authenticate/options). Format: `Bearer <token>`. Get the token via SIWE (/challenge → /verify) or passkey auth at the auth service.","example":"Bearer eyJhbGciOiJSUzI1NiIs..."},"required":false,"name":"authorization","in":"header"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"referralIds":{"type":"array","items":{"type":"string","format":"uuid"},"minItems":1,"maxItems":500,"description":"UUIDs of referrals to reassign (max 500 per request)"},"newInviterAddress":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"Target Ethereum address to transfer referrals to","example":"0x1234567890123456789012345678901234567890"}},"required":["referralIds","newInviterAddress"]}}}},"responses":{"200":{"description":"Reassignment result","content":{"application/json":{"schema":{"type":"object","properties":{"reassigned":{"type":"number","description":"Number of referrals successfully reassigned"},"skippedClaimed":{"type":"number","description":"Number skipped because status is 'claimed' (terminal)"},"skippedNotOwned":{"type":"number","description":"Number skipped because they don't belong to the authenticated user or don't exist"}},"required":["reassigned","skippedClaimed","skippedNotOwned"]}}}},"400":{"description":"Invalid input","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}},"401":{"description":"Missing or invalid session token","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message."}},"required":["error"]}}}}}}},"/distributions/sessions":{"post":{"tags":["Distributions"],"summary":"Create a distribution session","description":"Create a distribution session for an inviter. Returns a unique slug for QR codes / links. Auth required — body.inviterAddress must match authenticated address. The session owns its own key pool — add keys via POST /sessions/{id}/keys.","parameters":[{"schema":{"type":"string","description":"Bearer JWT token","example":"Bearer eyJ..."},"required":false,"name":"authorization","in":"header"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"inviterAddress":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"Inviter's Ethereum address","example":"0x1234567890123456789012345678901234567890"},"label":{"type":"string","maxLength":200,"description":"Human-readable label (e.g. 'ETHDenver 2026 booth')","example":"ETHDenver 2026 booth"},"expiresAt":{"type":"string","format":"date-time","description":"ISO 8601 expiry timestamp (optional)","example":"2026-03-01T00:00:00.000Z"},"metadata":{"type":"object","additionalProperties":{"nullable":true},"description":"Public metadata (returned on public endpoints). Max 4KB JSON object."},"privateMetadata":{"type":"object","additionalProperties":{"nullable":true},"description":"Private metadata (only returned on authenticated endpoints). Max 4KB JSON object."}},"required":["inviterAddress"]}}}},"responses":{"201":{"description":"Session created","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"inviterAddress":{"type":"string"},"label":{"type":"string","nullable":true},"expiresAt":{"type":"string","nullable":true},"paused":{"type":"boolean"},"queuedCount":{"type":"number"},"dispatchedCount":{"type":"number"},"claimedCount":{"type":"number"},"distributionUrl":{"type":"string","nullable":true,"description":"Full URL for QR codes (only when DISTRIBUTION_BASE_URL is configured)"},"metadata":{"type":"object","nullable":true,"additionalProperties":{"nullable":true},"description":"Public metadata (returned on public endpoints)"},"privateMetadata":{"type":"object","nullable":true,"additionalProperties":{"nullable":true},"description":"Private metadata (only returned on authenticated endpoints)"},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","slug","inviterAddress","label","expiresAt","paused","queuedCount","dispatchedCount","claimedCount","distributionUrl","metadata","privateMetadata","createdAt","updatedAt"]}}}},"400":{"description":"Invalid input","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"401":{"description":"Missing or invalid token","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"403":{"description":"Authenticated address does not match inviterAddress","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}}}},"get":{"tags":["Distributions"],"summary":"List distribution sessions","description":"List all distribution sessions for an inviter address, with pagination. Auth required — inviter query param must match authenticated address.","parameters":[{"schema":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"Filter by inviter address","example":"0x1234567890123456789012345678901234567890"},"required":true,"name":"inviter","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"default":20,"description":"Page size (1-100, default 20)"},"required":false,"name":"limit","in":"query"},{"schema":{"type":"integer","nullable":true,"minimum":0,"default":0,"description":"Pagination offset (default 0)"},"required":false,"name":"offset","in":"query"},{"schema":{"type":"string","description":"Bearer JWT token","example":"Bearer eyJ..."},"required":false,"name":"authorization","in":"header"}],"responses":{"200":{"description":"Paginated list of sessions","content":{"application/json":{"schema":{"type":"object","properties":{"sessions":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"inviterAddress":{"type":"string"},"label":{"type":"string","nullable":true},"expiresAt":{"type":"string","nullable":true},"paused":{"type":"boolean"},"queuedCount":{"type":"number"},"dispatchedCount":{"type":"number"},"claimedCount":{"type":"number"},"distributionUrl":{"type":"string","nullable":true,"description":"Full URL for QR codes (only when DISTRIBUTION_BASE_URL is configured)"},"metadata":{"type":"object","nullable":true,"additionalProperties":{"nullable":true},"description":"Public metadata (returned on public endpoints)"},"privateMetadata":{"type":"object","nullable":true,"additionalProperties":{"nullable":true},"description":"Private metadata (only returned on authenticated endpoints)"},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","slug","inviterAddress","label","expiresAt","paused","queuedCount","dispatchedCount","claimedCount","distributionUrl","metadata","privateMetadata","createdAt","updatedAt"]}},"total":{"type":"number"},"limit":{"type":"number"},"offset":{"type":"number"}},"required":["sessions","total","limit","offset"]}}}},"400":{"description":"Invalid query params","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"401":{"description":"Missing or invalid token","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"403":{"description":"Authenticated address does not match inviterAddress","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}}}}},"/distributions/sessions/{id}":{"get":{"tags":["Distributions"],"summary":"Get a distribution session","description":"Auth required — authenticated address must match inviterAddress.","parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","description":"Bearer JWT token","example":"Bearer eyJ..."},"required":false,"name":"authorization","in":"header"}],"responses":{"200":{"description":"Session details","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"inviterAddress":{"type":"string"},"label":{"type":"string","nullable":true},"expiresAt":{"type":"string","nullable":true},"paused":{"type":"boolean"},"queuedCount":{"type":"number"},"dispatchedCount":{"type":"number"},"claimedCount":{"type":"number"},"distributionUrl":{"type":"string","nullable":true,"description":"Full URL for QR codes (only when DISTRIBUTION_BASE_URL is configured)"},"metadata":{"type":"object","nullable":true,"additionalProperties":{"nullable":true},"description":"Public metadata (returned on public endpoints)"},"privateMetadata":{"type":"object","nullable":true,"additionalProperties":{"nullable":true},"description":"Private metadata (only returned on authenticated endpoints)"},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","slug","inviterAddress","label","expiresAt","paused","queuedCount","dispatchedCount","claimedCount","distributionUrl","metadata","privateMetadata","createdAt","updatedAt"]}}}},"401":{"description":"Missing or invalid token","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"403":{"description":"Authenticated address does not match inviterAddress","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"404":{"description":"Session not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}}}},"patch":{"tags":["Distributions"],"summary":"Update a distribution session","description":"Update session properties. Auth required — authenticated address must match inviterAddress.","parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","description":"Bearer JWT token","example":"Bearer eyJ..."},"required":false,"name":"authorization","in":"header"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"label":{"type":"string","maxLength":200},"expiresAt":{"type":"string","nullable":true,"format":"date-time","description":"New expiry (ISO 8601), or null to remove expiry"},"paused":{"type":"boolean"},"inviterAddress":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"Transfer ownership to a new Ethereum address. Only the current owner can initiate."},"metadata":{"type":"object","nullable":true,"additionalProperties":{"nullable":true},"description":"Set public metadata, or null to clear"},"privateMetadata":{"type":"object","nullable":true,"additionalProperties":{"nullable":true},"description":"Set private metadata, or null to clear"}}}}}},"responses":{"200":{"description":"Session updated","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"inviterAddress":{"type":"string"},"label":{"type":"string","nullable":true},"expiresAt":{"type":"string","nullable":true},"paused":{"type":"boolean"},"queuedCount":{"type":"number"},"dispatchedCount":{"type":"number"},"claimedCount":{"type":"number"},"distributionUrl":{"type":"string","nullable":true,"description":"Full URL for QR codes (only when DISTRIBUTION_BASE_URL is configured)"},"metadata":{"type":"object","nullable":true,"additionalProperties":{"nullable":true},"description":"Public metadata (returned on public endpoints)"},"privateMetadata":{"type":"object","nullable":true,"additionalProperties":{"nullable":true},"description":"Private metadata (only returned on authenticated endpoints)"},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","slug","inviterAddress","label","expiresAt","paused","queuedCount","dispatchedCount","claimedCount","distributionUrl","metadata","privateMetadata","createdAt","updatedAt"]}}}},"400":{"description":"Invalid update","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"401":{"description":"Missing or invalid token","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"403":{"description":"Authenticated address does not match inviterAddress","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"404":{"description":"Session not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}}}},"delete":{"tags":["Distributions"],"summary":"Delete a distribution session","description":"Delete a session. Rejected if any keys have status='dispatched' (in-flight). Auth required — authenticated address must match inviterAddress.","parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","description":"Bearer JWT token","example":"Bearer eyJ..."},"required":false,"name":"authorization","in":"header"}],"responses":{"200":{"description":"Session deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}},"required":["success"]}}}},"400":{"description":"Cannot delete session with dispatched keys","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"dispatchedCount":{"type":"number"}},"required":["error"]}}}},"401":{"description":"Missing or invalid token","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"403":{"description":"Authenticated address does not match inviterAddress","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"404":{"description":"Session not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}}}}},"/distributions/sessions/{id}/keys":{"post":{"tags":["Distributions"],"summary":"Add keys to a distribution session","description":"Add one or more private keys to a session's pool. Addresses are derived at insert time and on-chain status is batch-checked via multicall. Already-claimed keys are stored as 'claimed' (audit trail). Duplicate keys within the same session are silently ignored. Auth required — authenticated address must match inviterAddress.","parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","description":"Bearer JWT token","example":"Bearer eyJ..."},"required":false,"name":"authorization","in":"header"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"keys":{"type":"array","items":{"type":"string","pattern":"^0x[a-fA-F0-9]{64}$"},"minItems":1,"maxItems":100,"description":"Array of raw private keys (max 100 per request)"}},"required":["keys"]}}}},"responses":{"200":{"description":"Keys processed","content":{"application/json":{"schema":{"type":"object","properties":{"added":{"type":"number"},"skipped":{"type":"number"},"claimed":{"type":"number"},"confirmed":{"type":"number","description":"Keys already confirmed on-chain (AccountCreated exists)"},"errors":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string"},"error":{"type":"string"}},"required":["key","error"]}}},"required":["added","skipped","claimed","confirmed","errors"]}}}},"401":{"description":"Missing or invalid token","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"403":{"description":"Authenticated address does not match inviterAddress","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"404":{"description":"Session not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}}}},"get":{"tags":["Distributions"],"summary":"List keys in a distribution session","description":"Returns the full key list for a session with pagination. Auth required — authenticated address must match inviterAddress.","parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","description":"Filter by distribution status. Comma-separated for multiple: queued,dispatched","example":"queued"},"required":false,"name":"status","in":"query"},{"schema":{"type":"string","description":"Filter by on-chain lifecycle status. Comma-separated for multiple: stale,confirmed","example":"stale,confirmed"},"required":false,"name":"onchainStatus","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":200,"default":50,"description":"Page size (1-200, default 50)"},"required":false,"name":"limit","in":"query"},{"schema":{"type":"integer","nullable":true,"minimum":0,"default":0,"description":"Pagination offset"},"required":false,"name":"offset","in":"query"},{"schema":{"type":"string","description":"Bearer JWT token","example":"Bearer eyJ..."},"required":false,"name":"authorization","in":"header"}],"responses":{"200":{"description":"Key list","content":{"application/json":{"schema":{"type":"object","properties":{"keys":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"privateKey":{"type":"string","description":"Full private key"},"signerAddress":{"type":"string","nullable":true},"accountAddress":{"type":"string","nullable":true},"status":{"type":"string","enum":["queued","dispatched","claimed"]},"onchainStatus":{"type":"string","enum":["pending","confirmed","stale","claimed"]},"dispatchedAt":{"type":"string","nullable":true},"confirmedAt":{"type":"string","nullable":true},"staleAt":{"type":"string","nullable":true},"claimedAt":{"type":"string","nullable":true},"claimedBySource":{"type":"string","nullable":true},"addedAt":{"type":"string"}},"required":["id","privateKey","signerAddress","accountAddress","status","onchainStatus","dispatchedAt","confirmedAt","staleAt","claimedAt","claimedBySource","addedAt"]}},"total":{"type":"number"},"queuedCount":{"type":"number"},"dispatchedCount":{"type":"number"},"claimedCount":{"type":"number"},"limit":{"type":"number"},"offset":{"type":"number"}},"required":["keys","total","queuedCount","dispatchedCount","claimedCount","limit","offset"]}}}},"401":{"description":"Missing or invalid token","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"403":{"description":"Authenticated address does not match inviterAddress","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"404":{"description":"Session not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}}}}},"/distributions/sessions/{id}/keys/{keyId}":{"delete":{"tags":["Distributions"],"summary":"Remove a key from a distribution session","description":"Remove a single key by ID. Rejected if key has status='dispatched'. Auth required — authenticated address must match inviterAddress.","parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","format":"uuid"},"required":true,"name":"keyId","in":"path"},{"schema":{"type":"string","description":"Bearer JWT token","example":"Bearer eyJ..."},"required":false,"name":"authorization","in":"header"}],"responses":{"200":{"description":"Key removed","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}},"required":["success"]}}}},"400":{"description":"Key is currently dispatched","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"401":{"description":"Missing or invalid token","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"403":{"description":"Authenticated address does not match inviterAddress","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"404":{"description":"Session or key not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}}}}},"/d/{slug}":{"get":{"tags":["Distributions"],"summary":"Dispense a key via distribution session","description":"Dispense the next available invitation key from a distribution session's per-session pool. PRIMARY PATH: tries the queued pool first (no RPC call). FALLBACK PATH: if queued is empty, walks dispatched keys older than 5 min, checks on-chain status, recycles unclaimed ones.\n\n**Content negotiation:**\n- Browser (`Accept: text/html`) + `DISTRIBUTION_BASE_URL` → 302 redirect\n- Otherwise → JSON with privateKey, inviter, and optional claimUrl\n\n**Error codes:**\n- 404: Session not found\n- 410: Session expired or no keys available\n- 423: Session paused","parameters":[{"schema":{"type":"string","minLength":4,"maxLength":32,"pattern":"^[0-9A-Za-z]+$","description":"Distribution session slug (from QR code / link)","example":"aB3xK9mZ"},"required":true,"name":"slug","in":"path"}],"responses":{"200":{"description":"Key dispensed (JSON)","content":{"application/json":{"schema":{"type":"object","properties":{"privateKey":{"type":"string","pattern":"^0x[a-fA-F0-9]{64}$","description":"Full private key for the invitation link"},"inviter":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"Inviter address that owns this session"},"claimUrl":{"type":"string","description":"Pre-built claim URL (when DISTRIBUTION_BASE_URL configured)"},"sessionSlug":{"type":"string","description":"The session slug this key was dispensed through"},"metadata":{"type":"object","nullable":true,"additionalProperties":{"nullable":true},"description":"Public session metadata (e.g. group address for auto-trust, query params)"}},"required":["privateKey","inviter","sessionSlug","metadata"]}}}},"302":{"description":"Redirect to claim page (browser request)"},"404":{"description":"Session not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"410":{"description":"Session expired or no keys available","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"423":{"description":"Session paused","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}}}}},"/d/{slug}/stats":{"get":{"tags":["Distributions"],"summary":"Get distribution session stats (public)","description":"Returns aggregate key counts for a distribution session. No authentication required — the slug acts as a capability token. Response contains only counts, no sensitive data.","parameters":[{"schema":{"type":"string","minLength":4,"maxLength":32,"pattern":"^[0-9A-Za-z]+$","description":"Distribution session slug","example":"aB3xK9mZ"},"required":true,"name":"slug","in":"path"}],"responses":{"200":{"description":"Session stats","content":{"application/json":{"schema":{"type":"object","properties":{"queued":{"type":"number","description":"Keys waiting to be dispensed"},"dispatched":{"type":"number","description":"Keys dispensed, awaiting claim"},"claimed":{"type":"number","description":"Keys successfully claimed"},"total":{"type":"number","description":"Total keys in session"},"onchainPending":{"type":"number","description":"Keys with no on-chain activity yet"},"onchainConfirmed":{"type":"number","description":"Keys with AccountCreated event (Safe deployed)"},"onchainStale":{"type":"number","description":"Confirmed >1h without claim"},"onchainClaimed":{"type":"number","description":"Keys with AccountClaimed event (terminal)"},"metadata":{"type":"object","nullable":true,"additionalProperties":{"nullable":true},"description":"Public session metadata"}},"required":["queued","dispatched","claimed","total","onchainPending","onchainConfirmed","onchainStale","onchainClaimed","metadata"]}}}},"404":{"description":"Session not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}}}}}}}