Error Codes Reference
Guide to the OilPriceAPI error responses, their meanings, and how to handle them.
Error Response Formats
OilPriceAPI does not use a single error envelope. There are three distinct shapes depending on what went wrong. Handle each explicitly.
| Situation | HTTP Status | Body |
|---|---|---|
| Missing or invalid API key | 401 | JSON with a top-level error object |
| Access refused | 403 | JSON with a top-level error object |
| Invalid commodity code | 400 | JSON with status: "fail" and an error under data |
| Unknown route / endpoint | 404 | JSON with a top-level error object |
The error.docs link in a 401, 403 or 404 body points at the section of this page for its error.code, for example https://docs.oilpriceapi.com/guides/error-codes#UNAUTHORIZED.
401 — Missing or invalid API key
Both a missing key and an invalid key return the same shape with a single code, UNAUTHORIZED. There is no top-level status field and no meta block.
{
"error": {
"code": "UNAUTHORIZED",
"message": "Missing or invalid API key. Include header: Authorization: Token YOUR_API_KEY",
"status": 401,
"request_id": "fa0e389f-ca86-47b2-b167-2b4645a2fb8b",
"docs": "https://docs.oilpriceapi.com/guides/error-codes#UNAUTHORIZED",
"signup_url": "https://www.oilpriceapi.com/auth/signup",
"demo_endpoint": "/v1/demo/prices"
}
}
The error code lives at body.error.code and the request_id (useful for support) at body.error.request_id.
How to fix:
curl "https://api.oilpriceapi.com/v1/prices/latest?by_code=WTI_USD" \
-H "Authorization: Token YOUR_API_KEY" # Add or correct this header
- Verify the key in your dashboard.
- Check for typos or extra whitespace.
- Both
Token YOUR_API_KEYandBearer YOUR_API_KEYare accepted;Tokenis the recommended form.
403 — Feature not included in your plan (FORBIDDEN)
An endpoint or feature your plan does not include returns 403 with error.code FORBIDDEN. The body names the feature in error.required_feature and where to get access in error.upgrade_url.
How to fix: compare plans at the upgrade_url, or use an endpoint your plan includes. Retrying the same request will not succeed.
403 / 429 — Email confirmation required (EMAIL_CONFIRMATION_REQUIRED)
A new account can make 50 calls in total before its email address is confirmed. After that, calls are refused until you confirm. Confirming restores the plan's full limit right away.
The refusal arrives in one of two shapes, so match on the code in either place:
403witherror.codeEMAIL_CONFIRMATION_REQUIRED, pluserror.confirm_urlanderror.usage:
{
"error": {
"code": "EMAIL_CONFIRMATION_REQUIRED",
"message": "This key has used its 50 calls in total until you confirm your email. Confirm your email to restore your full …-request limit right away. Resend the confirmation email: https://www.oilpriceapi.com/auth/resend-confirmation",
"status": 403,
"request_id": "…",
"docs": "https://docs.oilpriceapi.com/guides/error-codes#EMAIL_CONFIRMATION_REQUIRED",
"confirm_url": "https://www.oilpriceapi.com/auth/resend-confirmation",
"usage": 50
}
}
429from the quota check, with top-levelerror_codeEMAIL_CONFIRMATION_REQUIREDand the confirmation link atoptions.confirm_email.url.
Until the cap starts, responses to an unconfirmed key carry an X-Account-Notice: email_unconfirmed header that says when it starts.
How to fix: click the link in the confirmation email, or request a new one at https://www.oilpriceapi.com/auth/resend-confirmation. Waiting or retrying does not lift this limit.
400 — Invalid commodity code
When you request a commodity code that doesn't exist, the response has a top-level status: "fail" and the error details are nested under data.
{
"status": "fail",
"data": {
"error": "invalid_code",
"message": "Code 'NOT_REAL' not found.",
"suggestions": [],
"invalid_codes": ["NOT_REAL"],
"all_codes_url": "https://www.oilpriceapi.com/v1/prices/codes"
}
}
Here the error string is at body.data.error (value "invalid_code"), and the offending codes are in body.data.invalid_codes.
How to fix:
# Use a valid commodity code. Fetch the full list from /v1/commodities.
import requests
headers = {'Authorization': 'Token YOUR_API_KEY'}
codes = requests.get(
'https://api.oilpriceapi.com/v1/commodities',
headers=headers,
).json()['data']
valid = {c['code'] for c in codes}
assert 'WTI_USD' in valid
The
all_codes_urlfield in the response points at/v1/prices/codes, which is not currently a working endpoint. For the authoritative list of commodity codes, useGET /v1/commoditiesinstead.
404 — Unknown commodity (details endpoint)
Requesting a specific commodity that doesn't exist — e.g. GET /v1/commodities/NOT_REAL — returns HTTP 404 with a JSON body whose code is COMMODITY_NOT_FOUND:
{
"status": "fail",
"error": {
"code": "COMMODITY_NOT_FOUND",
"message": "Commodity not found",
"commodity_code": "NOT_REAL"
}
}
Note this is a different shape from the 400 invalid_code returned by /v1/prices/latest?by_code=… — the error is under body.error (not body.data).
404 — Unknown route
Requesting a path the API doesn't recognize (a route that doesn't exist at all) returns HTTP 404 with a JSON body whose code is NOT_FOUND:
{
"error": {
"code": "NOT_FOUND",
"message": "No route matches GET /v1/nope. Check the path and the /v1 prefix; the machine-readable contract is at https://docs.oilpriceapi.com/api-reference.",
"status": 404,
"request_id": "…",
"docs": "https://docs.oilpriceapi.com/guides/error-codes#NOT_FOUND"
}
}
How to fix:
- Check the endpoint path against the documentation.
- Verify the API version prefix (
/v1). - Remove trailing slashes.
HTTP Status Codes
| Status | Meaning | Body |
|---|---|---|
200 | Success | JSON payload |
400 | Bad Request (e.g. invalid commodity code) | JSON, status: "fail" |
401 | Unauthorized — missing or invalid API key | JSON, top-level error object |
403 | Forbidden — feature not in your plan (FORBIDDEN), or email not confirmed (EMAIL_CONFIRMATION_REQUIRED) | JSON, top-level error object |
402 | Payment Required — Free-tier daily quota exhausted (error_code: PAYMENT_REQUIRED) | JSON, top-level error string; see Rate Limiting |
404 | Not Found — unknown route (NOT_FOUND) | JSON, top-level error object |
429 | Too Many Requests — per-minute rate limit exceeded (RATE_LIMIT_EXCEEDED), or a paid/trial quota exhausted (MONTHLY_QUOTA_EXCEEDED / TRIAL_LIMIT_EXCEEDED) | JSON, top-level error string; branch on error_code |
500 | Internal Server Error | — |
Error Handling Best Practices
1. Branch on status code, not a single envelope
Because the body shape differs by status, inspect the HTTP status first and only then reach for the matching field.
import requests
def handle_response(response):
"""Return parsed data on success, or raise with a useful message."""
if response.status_code == 200:
return response.json()
if response.status_code == 401:
# Top-level `error` object. request_id is available for support.
body = response.json()
err = body.get('error', {})
raise APIError(
f"Unauthorized ({err.get('code')}): {err.get('message')} "
f"[request_id={err.get('request_id')}]"
)
if response.status_code == 400:
# `status: "fail"` with details nested under `data`.
body = response.json()
data = body.get('data', {})
raise APIError(
f"Bad request ({data.get('error')}): {data.get('message')} "
f"invalid_codes={data.get('invalid_codes')}"
)
if response.status_code == 404:
# Unknown route: body.error.code == NOT_FOUND. Commodity details:
# COMMODITY_NOT_FOUND. Both are JSON; parse defensively anyway.
try:
body = response.json()
err = body.get('error', {})
raise APIError(f"Not found ({err.get('code', '404')}): {err.get('message', response.url)}")
except ValueError:
raise APIError('Not found (404): check the endpoint path and /v1 prefix')
if response.status_code == 429:
# Two causes share this status: the per-minute rate limit
# (RATE_LIMIT_EXCEEDED) and, on paid plans or the trial, an exhausted
# quota (MONTHLY_QUOTA_EXCEEDED / TRIAL_LIMIT_EXCEEDED). Only the
# first clears within seconds.
code = response.json().get('error_code')
if code == 'RATE_LIMIT_EXCEEDED':
raise APIError('Rate limited (429): retry with backoff')
raise APIError(f'Quota exhausted (429, {code}): wait for reset.date or change plan')
if response.status_code == 402:
# Free-tier daily quota is exhausted; waiting a few seconds will not fix it.
raise APIError('Free-tier daily quota exhausted (402): wait for reset.date or change plan')
response.raise_for_status()
2. Look up the right field per shape
There is no universal data.error.code path. Use the correct access path for each status:
| Status | Access path | Example value |
|---|---|---|
401 | body.error.code | "UNAUTHORIZED" |
401 | body.error.request_id | "fa0e389f-…" |
400 | body.data.error | "invalid_code" |
400 | body.data.invalid_codes | ["NOT_REAL"] |
403 | body.error.code | "FORBIDDEN" |
404 | body.error.code | "NOT_FOUND" |
// JavaScript: dispatch on status, then read the right field.
async function parseError(response) {
const status = response.status;
const body = await response.json();
if (status === 401 || status === 403 || status === 404) {
return {
status,
code: body.error.code, // "UNAUTHORIZED", "FORBIDDEN", "NOT_FOUND"
message: body.error.message,
requestId: body.error.request_id,
};
}
if (status === 400) {
return {
status,
code: body.data.error, // "invalid_code"
message: body.data.message,
invalidCodes: body.data.invalid_codes,
};
}
return { status, message: `Unexpected status ${status}` };
}
3. Retry logic
Retry transient failures (network errors, 429, 5xx) with backoff. Do not retry 400 or 401 — those require a fix on your side.
async function apiRequest(url, options, maxRetries = 3) {
let lastError;
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch(url, options);
if (response.ok) {
return await response.json();
}
// Client errors (except 429) are not retryable.
if (
response.status >= 400 &&
response.status < 500 &&
response.status !== 429
) {
lastError = await parseError(response);
break;
}
// 429 and 5xx: wait, then retry.
const retryAfter = Number(response.headers.get("Retry-After")) || i + 1;
await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
} catch (err) {
lastError = err;
// parseError failures are client errors, not transient transport errors.
if (err?.status >= 400 && err.status < 500) break;
await new Promise((resolve) => setTimeout(resolve, (i + 1) * 1000));
}
}
throw new Error(lastError?.message || 'Request failed');
}
Rate-Limit & Usage Headers
Successful (200) responses carry headers describing your request quota. These track the quota window (daily on Free, monthly on paid plans), not the 60-second rate-limit window.
| Header | Description |
|---|---|
X-RateLimit-Limit | Your request quota for the current window |
X-RateLimit-Remaining | Requests remaining in the current window |
X-RateLimit-Used | Requests used in the current window |
X-RateLimit-Reset | Epoch (seconds) when the quota window resets |
X-RateLimit-Window | The counting window: daily_counter on Free, monthly_counter on paid plans, trial_counter during the trial |
X-RateLimit-Tier | Your plan tier |
X-Request-Id | Unique request identifier |
X-Cache | Cache status for the response |
response = requests.get(url, headers=headers)
remaining = int(response.headers.get('X-RateLimit-Remaining', 0))
if remaining < 100:
logging.warning(f"Only {remaining} requests left in this quota window")
Support
For persistent errors:
- Email: support@oilpriceapi.com
- Include the
request_idfrom the response when available.401,403and404responses carry it atbody.error.request_id; for any response without one, include the exact URL you requested and theX-Request-Idheader.