Rate limits and idempotency
Rate limits
Six counters, in fixed one-minute windows. A call goes through all of them; the first one full answers 429 API_RATE_LIMITED with Retry-After.
| Counter | Ceiling | Counted per | Applies to |
|---|---|---|---|
| Address | 1 200 / min | calling IP address | every /v1 call, before the key is even read |
| Reads | 600 / min | key | GET, HEAD, OPTIONS |
| Writes | 60 / min | key | POST, PATCH, DELETE |
| Power | 20 / min | key and service | POST /services/{code}/power |
| Reinstall | 2 / min | key and service | POST /services/{code}/reinstall |
| Account | 1 200 / min | account | every call, across all keys |
Three details that change how a retry loop behaves:
- A refused call counts. A burst of
400eats the write budget like successful calls do. Fix the body before retrying. - Rotating a key resets its counters. Per-key counters are indexed on the secret's identity, and a rotation creates a new one.
- Counters are local to each server. Behind several replicas, the ceiling actually reached can go up to twice the nominal one.
Reading the headers
| Header | Value |
|---|---|
X-RateLimit-Limit | the ceiling of the tightest of the six counters |
X-RateLimit-Remaining | what is left on that counter |
X-RateLimit-Reset | epoch timestamp in seconds of the end of the window, not a delay |
Retry-After | on 429 only: seconds to wait, minimum 1 |
These headers describe the tightest counter at that moment, which is not always the same one from call to call.
Idempotency
Four routes move money and require an Idempotency-Key header:
POST /ordersPOST /orders/{orderNumber}/payPOST /invoices/{code}/payPOST /services/{code}/renew
| Idempotency key | Detail |
|---|---|
| Format | non-blank string, 128 characters at most. A UUID does |
| Absent | 400 IDEMPOTENCY_KEY_REQUIRED |
| Blank or too long | 400 IDEMPOTENCY_KEY_INVALID |
| Scope | the API key. Two keys may use the same string without colliding |
| Retention | 24 hours |
What makes it "the same request"
The digest covers the method, the concrete path and the body. The same idempotency key on another order number is therefore a different request.
| Situation | Answer |
|---|---|
| Identical replay after a success | the recorded response, with Idempotent-Replayed: true. Nothing is redone |
| Same key, different request | 409 IDEMPOTENCY_KEY_REUSED |
| Same key while the first call is still running | 409 IDEMPOTENCY_IN_PROGRESS with Retry-After |
Refusals replay too
A refusal is a result: it is recorded and replayed. A replayed 402 PAYMENT_DENIED stays a 402, with no second call to the bank.
The exception is a refusal raised before any write and any charge. Those release the key: fix the condition and retry with the same one.
| Releases the key | Does not release it |
|---|---|
INSUFFICIENT_BALANCE | PAYMENT_DENIED |
NO_PAYMENT_METHOD | PAYMENT_RECORDED_MISMATCH |
PAYMENT_METHOD_ID_REQUIRED | API_KEY_SPENDING_CAP_EXCEEDED |
PAYMENT_METHOD_NOT_FOUND | PAYMENT_FAILED |
PAYMENT_METHOD_NOT_ACTIVE | any 500 |
PAYMENT_METHOD_DECRYPTION_FAILED | |
PAYMENT_METHOD_VAULT_ID_MISSING | |
PAYMENT_IN_PROGRESS | |
RENEWAL_IN_PROGRESS | |
RENEWAL_TOO_FREQUENT |
API_KEY_SPENDING_CAP_EXCEEDED sits in the right-hand column even though nothing was charged: on POST /orders the authoritative check runs after the order row is written, and releasing the key would let a retry create a second one. Raise the cap, then retry with a new idempotency key.
In practice
- Generate the idempotency key before the first send, and store it with the task, not in memory.
- Reuse it as is for every retry of the same task.
- Change it as soon as the task changes, even by one cent.