Idempotency
A request might time out before you read the response. You don’t know if it ran. Replaying it could create a duplicate payout, beneficiary, or quote.
The Idempotency-Key header solves this. Pass a unique value on every write request. We cache the response for 24 hours keyed by (organization, key, method, path). A retry within that window returns the cached response. The handler does not run twice.
How to use it
Generate a unique key per operation (UUID v4 is fine) and send it on any non-GET request:
curl -X POST https://api.nxos.io/v1/quotes \
-H "Authorization: Bearer nxos_sk_test_..." \
-H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
-H "Content-Type: application/json" \
-d '{
"accountId": "acct_...",
"fromAsset": "USD",
"toAsset": "USDC",
"fromAmount": "100.00"
}'
The header is optional. If you omit it, the request runs as usual with no replay protection.
Outcomes
| Scenario | Outcome |
|---|
| First request with a fresh key | Handler runs. Response is cached and returned. |
| Retry, same key, same body, response cached | Cached response is returned. The Idempotent-Replayed: true header is set so you can detect the cache hit. |
| Retry, same key, same body, original still in flight | 409 idempotency_request_in_flight. Wait briefly and retry. |
| Retry, same key, different body | 409 idempotency_key_in_use. You reused the key for a different operation. Generate a new key. |
Original returned 5xx | Not cached. The next attempt re-acquires the key and runs the handler. |
Caching policy
We cache 2xx and 4xx responses. Same key + same body always returns the same response.
We do not cache 5xx. Server errors are usually transient. Your retry should hit a healthy server, not a stale cached failure.
Key requirements
- Maximum 255 characters
- Unique per logical operation (UUID v4 recommended)
- Reuse the same key for retries of the same operation. Do not reuse it for a different operation.
Scope
Keys are scoped per organization, per HTTP method, per request path. The same key value is independent across POST /v1/quotes and POST /v1/transactions/crypto-payouts. They are different operations.
GET endpoints ignore the header.
Rate limits
Every API endpoint is rate-limited per organization. The default cap is 1,000 requests per minute, applied as a tumbling 60-second window.
Per organization, not per key
Limits apply to your organization. Creating more API keys does not raise your quota. Your throughput is one number across all your keys.
Every successful response (and every 429) includes these headers so you can pace yourself before hitting the limit:
| Header | Value |
|---|
X-RateLimit-Limit | The cap configured for your organization. |
X-RateLimit-Remaining | Requests left in the current window. |
X-RateLimit-Reset | Unix timestamp (seconds) when the window resets. |
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 873
X-RateLimit-Reset: 1714323600
Content-Type: application/json
When you exceed the limit
Requests beyond the cap return 429 rate_limited with a Retry-After header indicating seconds until the window resets:
HTTP/1.1 429 Too Many Requests
Retry-After: 23
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1714323623
Content-Type: application/json
{
"error": {
"code": "rate_limited",
"message": "Rate limit exceeded: 1000 requests per minute. Retry in 23 seconds.",
"requestId": "req_abc..."
}
}
The next window admits you immediately. Wait for Retry-After rather than retrying right away.
Handling 429 in your client
async function callWithBackoff(url, init, maxAttempts = 3) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const response = await fetch(url, init);
if (response.status !== 429) return response;
const retryAfter = Number(response.headers.get('Retry-After') ?? '1');
await new Promise((r) => setTimeout(r, retryAfter * 1000));
}
throw new Error('Rate limited after retries');
}
IP-based protection
A separate edge rule (CloudFront WAF) caps requests per source IP for abuse and DDoS protection. That layer is independent of the per-organization quota above. Most partners never see it. If you do, contact support.
How they interact
A 429 response is not cached. Your next attempt with the same Idempotency-Key re-runs against the rate-limited handler. Once the window resets, the request is admitted normally.
A cached replay (Idempotent-Replayed: true) does count against your rate limit. The cached response is still a request you sent us. If you see high replay volume, your client is sending the same request more often than it needs to. Check the client logic.
Send an Idempotency-Key on every write request. Honor Retry-After on 429. That covers the two most common ways an integration breaks.