Rate Limits
PreflightAPI enforces two types of throttling: rate limits (requests per 60-second window) and monthly quotas (total calls per billing period). Both depend on your subscription plan.
Limits by Plan
| Plan | Rate Limit | Monthly Quota |
|---|---|---|
| Student Pilot | 10 requests / 60 sec | 5,000 calls |
| Private Pilot | 60 requests / 60 sec | 150,000 calls |
| Commercial Pilot | 300 requests / 60 sec | 750,000 calls |
Rate limits are enforced on a sliding 60-second window per subscription key. If you exceed the limit, further requests in that window are rejected with 429 Too Many Requests until the window resets.
Monthly Quotas
In addition to per-minute rate limits, each plan has a monthly quota that caps the total number of API calls in a billing period. Quota counters reset at the start of each monthly billing cycle.
- When you hit your monthly quota, all further requests return
429 Too Many Requestswith aQuotaExceedederror until the quota resets. - You can track your current usage on the dashboard overview page.
- Upgrading your plan immediately increases both your rate limit and monthly quota.
Rate Limit Headers
Every API response includes headers that let you monitor your rate limit usage in real time:
HTTP/1.1 200 OK
Content-Type: application/json
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 58| Header | Description | Present On |
|---|---|---|
X-RateLimit-Limit | Maximum requests allowed in the current 60-second window | Every response |
X-RateLimit-Remaining | Requests remaining before you hit the rate limit | Every response |
Retry-After | Seconds to wait before retrying | 429 responses only |
Data Currency Headers
In addition to rate limit headers, every successful response from a data endpoint includes data currency headers (X-Data-Currency, X-Data-Last-Updated, X-Data-Sync-Age-Minutes) that indicate how current the underlying data is. See the data currency guide for details on staleness detection and severity levels.
Exceeding Limits
Both rate limit and quota errors return 429 Too Many Requests. You can distinguish them by the error field in the response body.
Rate limit exceeded (429)
When you exceed your per-minute rate limit, the API returns 429 Too Many Requests with a standard Retry-After header and a retryAfterSeconds field in the body:
{
"error": "RateLimitExceeded",
"message": "Too many requests. Please slow down and try again shortly.",
"retryAfterSeconds": 45
}Monthly quota exceeded (429)
When you exhaust your monthly quota, the API returns 429 Too Many Requests with a quotaResetsAt timestamp indicating when your quota renews:
{
"error": "QuotaExceeded",
"message": "You have reached your monthly API call limit.",
"quotaResetsAt": "2026-03-15T06:00:00.0000000Z"
}The quotaResetsAt value is an ISO 8601 UTC timestamp. The quota resets at the start of your next billing cycle.
Check the error field to distinguish rate-limit (RateLimitExceeded) from quota (QuotaExceeded) responses. See the error handling guide for details on all error formats.
Response Caching
GET responses are cached at the API gateway to reduce latency. Cache duration varies by data type. Cached responses are identical to fresh responses and still count toward your rate limit and monthly quota.
| Endpoint Category | Cache Duration |
|---|---|
| Real-time weather (METARs, PIREPs) | 2 minutes |
| E6B calculations (live METAR mode) | 2 minutes |
| Forecasts (TAFs, SIGMETs, G-AIRMETs) | 5 minutes |
| NOTAMs | 5 minutes |
| Winds aloft | 5 minutes |
| Presigned URLs (terminal procedures, chart supplements) | 10 minutes |
| Static / NASR data (airports, frequencies, airspace, obstacles, NAVAIDs) | 15 minutes |
Only GET requests are cached. POST endpoints are never cached.
Tip
staleTime in TanStack Query), match them to these cache durations for optimal freshness without redundant requests.Monitoring Your Usage
- Dashboard — The dashboard overview shows your current monthly usage and remaining quota at a glance.
- Response headers — Check
X-RateLimit-Remainingafter each request to track your real-time rate limit usage. - Plan ahead — If you're consistently hitting your limits, consider upgrading your plan for higher throughput.
Best Practices
- Cache locally — Store responses on your side to avoid redundant requests. Match the cache TTL to the gateway cache duration for optimal freshness.
- Use exponential backoff — When you receive a
429, wait for theRetry-Afterduration before retrying. Use exponential backoff with jitter to avoid thundering herds. - Monitor headers — Check
X-RateLimit-Remainingto proactively slow down before hitting the rate limit. - Batch where possible — Some endpoints accept multiple identifiers in a single call (e.g., fetching METARs for multiple ICAO codes). Use these to reduce the number of requests.
Retry with Exponential Backoff
Here's a reusable fetch wrapper that automatically retries on 429 responses with exponential backoff and jitter:
async function fetchWithRetry(
url: string,
options: RequestInit,
maxRetries = 3,
): Promise<Response> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fetch(url, options)
if (response.status !== 429) {
return response
}
// Check if this is a quota error (not retryable)
const body = await response.clone().json()
if (body.error === 'QuotaExceeded') {
throw new Error(`Monthly quota exceeded. Resets at ${body.quotaResetsAt}`)
}
if (attempt === maxRetries) {
throw new Error('Rate limit exceeded after max retries')
}
// Use Retry-After header or retryAfterSeconds from body
const retryAfter = response.headers.get('Retry-After')
const baseDelay = retryAfter
? parseInt(retryAfter, 10) * 1000
: (body.retryAfterSeconds ?? Math.pow(2, attempt)) * 1000
// Add random jitter (0-500ms) to prevent thundering herd
const jitter = Math.random() * 500
await new Promise((resolve) => setTimeout(resolve, baseDelay + jitter))
}
throw new Error('Unreachable')
}
// Usage
const response = await fetchWithRetry(
'https://api.preflightapi.io/api/v1/metars/KJFK',
{
headers: {
'Ocp-Apim-Subscription-Key': process.env.PREFLIGHT_API_KEY!,
},
},
)
const data: Metar = await response.json()