Cooldowns and rate limits
The embed API has several layered limits. Some are global (per-key, per-IP), some are session-scoped, and one is per-wallet. A single request can be rejected by any of these; the error code on the response tells you which bucket hit.
Summary
| Scope | Error code | Applies to |
|---|---|---|
| API key | rate_limit_key (429) | All endpoints |
| Client IP | rate_limit_ip (429) | All endpoints |
| Session (nonce) | rate_limit_session (429) | POST /siws/nonce |
| Session (submit) | rate_limit_session (429) | POST /submissions |
| Wallet + page | cooldown_active (429) | POST /submissions |
| Tracking page | queue_full (429) | POST /submissions |
All 429 responses include a Retry-After header in seconds. Always honor it.
Global: per-key and per-IP
Every request to every /api/embed/* endpoint passes through these two buckets. The per-key limiter counts against your API key; the per-IP limiter counts against the client IP. They return different error codes (rate_limit_key vs. rate_limit_ip) so you can distinguish them when many keys share an egress IP.
HTTP/1.1 429 Too Many Requests
Retry-After: <seconds>
{ "error": "rate_limit_key" }
Session-scoped
Both session limits require a valid embed_session cookie. Without the cookie, the session-scoped buckets are skipped (but per-key and per-IP still apply). The iframe sets the cookie automatically; custom UIs that never load the iframe won't have it.
POST /siws/nonce has a nonce-issuance cap per session, intended to stop a single visitor from exhausting nonce entropy or masking signature-verification spam. POST /submissions has a total-submissions cap per session, which bounds the volume of wallets one visitor can try to push independent of individual outcomes. Both return rate_limit_session with a Retry-After.
Per-wallet cooldown
When a submission for a wallet is rejected (manually by the owner or via a short-circuit like CA-gate failure), that wallet is placed on cooldown against the same tracking page for a short period. New submissions for that wallet on that page fail with:
HTTP/1.1 429 Too Many Requests
Retry-After: <seconds>
{ "error": "cooldown_active" }
The cooldown is scoped to (walletAddress, trackingPageId). A rejected wallet can still submit to a different tracking page immediately. The cooldown doesn't apply to successful submissions — the duplicate check already prevents those from resubmitting.
Queue cap
POST /submissions checks the tracking page's pending-submission count against a configurable cap. If the queue is at capacity:
HTTP/1.1 429 Too Many Requests
Retry-After: <seconds>
{ "error": "queue_full" }
The counter decrements when the owner approves or rejects. This cap is a policy setting — different pages can be tuned differently. See Verification policy.
Recovery strategies
| Error | Strategy |
|---|---|
rate_limit_key | Back off and honor Retry-After. Sustained hits mean you're over budget — slow your polling. |
rate_limit_ip | Back off and honor Retry-After. Often means many users share one IP (corporate NAT). |
rate_limit_session (nonce) | Back off and honor Retry-After. Don't retry nonce issuance aggressively on transient errors. |
rate_limit_session (submit) | Back off and honor Retry-After. |
cooldown_active | Back off and honor Retry-After. Fix whatever caused the rejection before retrying. |
queue_full | Wait or ask the page owner to clear the queue. |
What isn't rate-limited
- Reading the public payload via
GET /tracking/{id}has no request-shape quota beyond the global per-key and per-IP buckets. GET /session/submissionsis subject only to the global buckets.
Related
- Verification policy — the queue cap lives here.
- Errors and status codes — every code, not just the rate-limit ones.