Skip to main content

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:

AuthenticationLimitPeriodScope
API Key1,000 requestsPer hourPer key
JWT Token1,000 requestsPer hourPer token
Session (Admin)1,000 requestsPer hourPer user
Unauthenticated100 requestsPer hourPer IP

Endpoint-Specific Limits​

Certain endpoints have stricter per-minute limits to prevent abuse:

EndpointLimitScope
Login60 requests per minute (per IP), 15 per minute (per email)Per IP / Per email
Token Refresh30 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 otherwisePer user (JWT) or per key
General API1,000 requests per hourPer API key

Rate Limit Headers​

When a rate limit is exceeded, the API returns a 429 Too Many Requests response with the following headers:

HeaderDescription
Retry-AfterSeconds until the rate limit resets (per RFC 7231)
X-RateLimit-LimitMaximum requests allowed in the current window
X-RateLimit-RemainingRequests remaining in the current window
X-RateLimit-ResetUTC 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.

UsageStatusBehavior
< 100%OKNormal operation
100%WARNINGNotification email sent, uploads continue
110%ALERTNotification email sent, uploads continue
120%SOFT_THROTTLENotification email sent, uploads continue
150%HARD_CAPNotification 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"},
)
Use the Retry-After Value

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_EXCEEDED and QUOTA_UNAVAILABLE
  • Best Practices: Production patterns for retry logic and connection management