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

CodeMeaningReturned by
200 OKRequest succeeded.health, verify, poll
202 AcceptedMessages accepted into the delivery queue.send
400 Bad RequestMalformed body, missing messages array, empty batch, batch over 50, invalid limit query, message over 10,000 chars.poll (bad limit), send (body validation)
401 UnauthorizedMissing key header, wrong-header-for-credential-type, key not associated with any appId, or an auth/verify lookup failure.verify, poll, send
403 ForbiddenThe key was recognized but is invalid on poll/send specifically. (Different code from the 401 above by design.)poll, send
404 Not FoundUnmatched route or empty poll results on some legacy paths.any unmatched path
429 Too Many RequestsRate limit exceeded. Headers X-RateLimit-Remaining: 0 and X-RateLimit-Reset: <epochMs>.poll, send
503 Service UnavailableRedis 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.

EndpointDefault windowFree tierPaid tier
GET /api/v1/messages/poll60s100 requests100 requests
POST /api/v1/messages/send60s600 messages2000 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.

Last updated: