Rate Limiting
SpatialFlow enforces rate limits to ensure fair usage and platform stability. Rate limiting operates at two layers: middleware-level (global, per-request) and decorator-level (per-endpoint).
Rate Limit Tiers
All API requests are subject to global rate limits based on the authentication method used:
| Authentication | Limit | Period | Scope |
|---|---|---|---|
| API Key | 1,000 requests | Per hour | Per key |
| JWT Token | 1,000 requests | Per hour | Per token |
| Session (Admin) | 1,000 requests | Per hour | Per user |
| Unauthenticated | 100 requests | Per hour | Per IP |
Endpoint-Specific Limits
Certain endpoints have stricter per-minute limits to prevent abuse:
| Endpoint | Limit | Scope |
|---|---|---|
| Login | 60 requests per minute (per IP), 15 per minute (per email) | Per IP / Per email |
| Token Refresh | 30 requests per minute (per token), 100 per minute (per IP) | Per token / Per IP |
Location ingestion (POST /devices/batch-update, POST /devices/{id}/location) | 20,000 requests per hour for a JWT-authenticated caller; the generic 1,000/hour budget otherwise | Per user (JWT) or per key |
| General API | 1,000 requests per hour | Per API key |
Rate Limit Headers
When a rate limit is exceeded, the API returns a 429 Too Many Requests response with the following headers:
| Header | Description |
|---|---|
Retry-After | Seconds until the rate limit resets (per RFC 7231) |
X-RateLimit-Limit | Maximum requests allowed in the current window |
X-RateLimit-Remaining | Requests remaining in the current window |
X-RateLimit-Reset | UTC epoch timestamp when the window resets |
Example 429 response with headers:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 3600
X-Request-ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890
Rate Limit Response Format
When a global rate limit is exceeded, the API returns the following JSON body:
{
"detail": "Rate limit exceeded",
"error_code": "RATE_LIMIT_EXCEEDED",
"details": {
"limit": 1000,
"reset_at": "2025-11-10T11:00:00Z",
"retry_after": 3600
}
}
The details object includes the limit that was exceeded, an ISO timestamp for when the window resets (reset_at), and the number of seconds to wait (retry_after).
Event Quota
Each subscription plan includes a monthly event allowance. Every accepted location point and every geofence enter or exit counts as an event; see what counts as an event. The allowance never limits location uploads. POST /devices/batch-update, POST /devices/{device_id}/location, POST /locations and POST /locations/batch accept and store points at every usage level, and the points still count.
For a paid subscription marked canceled or past_due, usage is measured against the Free event allowance once its billing period has ended, even before the daily downgrade task runs. Past-due status alone does not change the allowance before that boundary, and active/trialing subscriptions keep their plan allowance. This does not reset usage or change billing records.
| Usage | Status | Behavior |
|---|---|---|
| < 100% | OK | Normal operation |
| 100% | WARNING | Notification email sent, uploads continue |
| 110% | ALERT | Notification email sent, uploads continue |
| 120% | SOFT_THROTTLE | Notification email sent, uploads continue |
| 150% | HARD_CAP | Notification email sent, uploads continue |
The throttle_status field of GET /api/v1/subscriptions/usage reports these bands as ok, warning, alert, throttled and blocked. The names are historical: no band refuses or slows a request.
The one refusal tied to the quota is a workspace with no event plan to count against, for example one with no subscription and no active Free plan to assign. Location uploads then fail closed with 403 and error_code QUOTA_UNAVAILABLE:
{
"detail": "Event quota is unavailable. Please contact support.",
"error_code": "QUOTA_UNAVAILABLE"
}
Asynchronous Upload Admission and Retries
Public uploads require an already registered, active device. API keys need devices:write permission. Each newly admitted fix counts one event when it is queued. Geofence events generated by admitted fixes are delivered and counted too.
A 202 response means the accepted points are durably recorded for processing, even if the queue broker is temporarily unavailable. The ids field contains receipt IDs; the legacy task_ids field can be empty while recovery is pending. Pending work is retried automatically. Retrying the exact device, timestamp, coordinates, and optional location fields reuses the receipt without another ingest charge. Completed receipts retain only their fingerprint and identifiers for at least 48 hours after processing; their location payload is cleared immediately. Pending payloads remain until successfully processed or their device/workspace is deleted, and their reservation is not released after an uncertain broker response.
A partially admitted public batch returns 202, its accepted receipt IDs, and rejection errors indexed to the original input. Retry only the rejected points when permitted. If no points are admitted, the response takes its status from a rejection and lists every rejection in errors.
If a device is disabled before an admitted fix is processed, that receipt is terminally rejected: its payload is cleared and it is not queued again. The admission charge remains recorded. Replaying the exact fix returns 400 with DEVICE_DISABLED and its receipt_id, including after the device is re-enabled, while the completed receipt is retained. Disabling a device does not undo fixes that were already processed.
Handling Rate Limits
Check the Retry-After Header
Always read the Retry-After header from 429 responses before retrying. This header tells you exactly how many seconds to wait.
Implement Exponential Backoff
For transient rate limits, use exponential backoff with jitter to avoid thundering herd problems:
import time
import random
import requests
def call_api_with_retry(url, headers, max_retries=5):
"""Call the SpatialFlow API with automatic retry on rate limits."""
for attempt in range(max_retries):
response = requests.get(url, headers=headers)
if response.status_code != 429:
return response
# Use Retry-After header if available
retry_after = int(response.headers.get("Retry-After", 60))
# Add jitter to avoid thundering herd
jitter = random.uniform(0, retry_after * 0.1)
wait_time = retry_after + jitter
print(f"Rate limited. Retrying in {wait_time:.1f}s (attempt {attempt + 1})")
time.sleep(wait_time)
raise Exception("Max retries exceeded")
# Usage
response = call_api_with_retry(
"https://api.spatialflow.io/api/v1/geofences",
headers={"X-API-KEY": "YOUR_API_KEY"},
)
Do not guess how long to wait. The Retry-After header provides the exact number of seconds until your rate limit resets. Always prefer this value over a hardcoded delay.
Testing Rate Limits
You can observe rate limit behavior with a simple curl request:
# Send a request and inspect response headers
curl -i https://api.spatialflow.io/api/v1/geofences \
-H "X-API-KEY: YOUR_API_KEY"
# When rate limited, you'll see:
# HTTP/1.1 429 Too Many Requests
# Retry-After: 3600
# Content-Type: application/json
#
# {"detail":"Rate limit exceeded","error_code":"RATE_LIMIT_EXCEEDED","details":{"limit":1000,"reset_at":"2025-11-10T11:00:00Z","retry_after":3600}}
Further Reading
- Error Reference: Full list of error codes including
RATE_LIMIT_EXCEEDEDandQUOTA_UNAVAILABLE - Best Practices: Production patterns for retry logic and connection management