Errors & Rate Limits
Every status code PrivacyFlow returns, the 401-vs-403 distinction, and how rate limits are bucketed per credential.
PrivacyFlow returns a small, predictable set of status codes and a uniform { "error": "…" } body for every failure case. Rate limits are per-credential, in-memory, and per-process — knowing the shape matters when you architect for scale.
Status codes
| Code | Meaning | Returned by |
|---|---|---|
200 OK | Request succeeded. | health, verify, poll |
202 Accepted | Messages accepted into the delivery queue. | send |
400 Bad Request | Malformed body, missing messages array, empty batch, batch over 50, invalid limit query, message over 10,000 chars. | poll (bad limit), send (body validation) |
401 Unauthorized | Missing key header, wrong-header-for-credential-type, key not associated with any appId, or an auth/verify lookup failure. | verify, poll, send |
403 Forbidden | The key was recognized but is invalid on poll/send specifically. (Different code from the 401 above by design.) | poll, send |
404 Not Found | Unmatched route or empty poll results on some legacy paths. | any unmatched path |
429 Too Many Requests | Rate limit exceeded. Headers X-RateLimit-Remaining: 0 and X-RateLimit-Reset: <epochMs>. | poll, send |
503 Service Unavailable | Redis poll failure or BullMQ enqueue failure — transient backend issue. | poll, send |
Error body shape
Every error response is JSON:
{ "error": "human-readable message" }
The message is stable across versions for the same failure mode — safe to switch on in client code, though prefer checking the status code.
The 401-vs-403 split
/api/v1/auth/verify returns 401 for an invalid API key. /api/v1/messages/poll and /api/v1/messages/send return 403 for an invalid API key. Both return 401 for a missing header or a wrong-header-for-credential-type mismatch.
Treat it as:
- 401 = the request didn’t authenticate at all (fix your config or your key string).
- 403 = the request authenticated but the credential isn’t permitted to call this endpoint (fix your dashboard credential scope or appId mapping).
Rate limits
Two separate limits, two separate key namespaces.
| Endpoint | Default window | Free tier | Paid tier |
|---|---|---|---|
GET /api/v1/messages/poll | 60s | 100 requests | 100 requests |
POST /api/v1/messages/send | 60s | 600 messages | 2000 messages |
Note
For send, the limit counts messages in the batch, not requests. A single 50-message POST consumes 50 units against your send budget. If the batch alone would exceed the remaining budget the entire request is rejected with 429.
Bucket key: credentialId, not key string
Each credential has a stable credentialId stored alongside the key. Rate buckets are keyed on credentialId, with the raw key string as a fallback. Rotating your key keeps your rate budget — you don’t reset to a fresh 600/100 by rotating. See Concepts.
Separate poll and send namespaces
The two limits do not share a counter. Exhausting your poll budget does not block sends, and vice versa.
In-memory, per-process
The limiter is an in-memory Map per process. A multi-replica deployment gives each replica its own budget. If you (or your provider) run the API behind multiple pods, your effective limit is N × tier_limit. This behavior may change without notice. A single-pod deployment hits one shared cap; a horizontally-scaled deployment is more forgiving.
The bucket store runs a cleanup sweep every 300 seconds to evict expired entries, so memory stays bounded under bursty traffic.
Response headers
On every successful poll and send response the API sets:
X-RateLimit-Remaining: <int>
On a 429 it additionally sets:
X-RateLimit-Reset: <epoch_ms>
Read X-RateLimit-Remaining on each call and back off locally before you trip the server-side 429. The MCP server and Agent Zero channel both do this.
Security headers
Every response — including errors — carries:
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block
Strict-Transport-Security: max-age=31536000; includeSubDomains
HSTS is unconditional. Plan for HTTPS-only clients.