Get Latest Prices
Status: β Available | π Source timestamps | π Catalog endpoint
Returns the latest available price for a commodity, with source and freshness context when available. Call with a single by_code, or pass a comma-separated list to get several at once. Called with no by_code, it returns a single default commodity (BRENT_CRUDE_USD) β not every commodity.
Authentication
See Authentication Guide for API key setup.
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
by_code | string | No | Commodity code, or several comma-separated (e.g. WTI_USD,BRENT_CRUDE_USD). Omit to get the default code. |
Response
Success (200) β Single commodity
data is a flat price object (not nested by commodity code):
{
"status": "success",
"data": {
"price": 68.58,
"formatted": "$68.58",
"currency": "USD",
"code": "WTI_USD",
"created_at": "2026-07-03T13:43:01.099Z",
"updated_at": "2026-07-03T13:43:01.099Z",
"type": "spot_price",
"unit": "barrel",
"source": "market_reporting",
"data_status": "current",
"freshness": {
"status": "current",
"age_seconds": 275,
"expected_max_age_seconds": 1800
},
"changes": {
"24h": {
"amount": 0.72,
"percent": 1.06,
"previous_price": 67.86,
"previous_timestamp": "2026-07-02T13:43:01Z",
"measured_at": "2026-07-03T13:43:01Z",
"span_hours": 24.0
},
"7d": { "amount": -1.01, "percent": -1.45, "previous_price": 69.59 },
"30d": { "amount": -26.41, "percent": -27.8, "previous_price": 94.99 },
"90d": { "amount": -42.96, "percent": -38.52, "previous_price": 111.54 }
},
"metadata": {
"source": "market_reporting",
"source_description": "Investing.com"
}
}
}
Success (200) β Multiple commodities
When you pass several codes, prices come back as an array under data.prices (not keyed by code), alongside a data.metadata block:
{
"status": "success",
"data": {
"prices": [
{
"price": 68.58,
"code": "WTI_USD",
"formatted": "$68.58",
"currency": "USD",
"type": "spot_price",
"unit": "barrel",
"data_status": "current"
},
{
"price": 71.98,
"code": "BRENT_CRUDE_USD",
"formatted": "$71.98",
"currency": "USD",
"type": "spot_price",
"unit": "barrel",
"data_status": "current"
}
],
"metadata": {
"request_id": "6390b34903a8d3f0",
"timestamp": "2026-07-03T13:47:37Z",
"version": "v1"
}
}
}
Response Fields
Note: For a single code the price object sits directly in
data. For multiple codes the objects are in thedata.pricesarray.
| Field | Type | Description |
|---|---|---|
price | number | Current price value (rounded to 2 decimals) |
formatted | string | Price formatted with currency symbol (e.g., "$68.58") |
currency | string | Price currency code (USD, EUR, GBP) |
code | string | Commodity code (e.g., "WTI_USD") |
created_at | string | ISO 8601 timestamp with milliseconds (UTC) |
observed_at | string | ISO 8601 source observation timestamp when available; for source-dated daily data this is the reference date |
source_date | string | Date-only source reference date when available, e.g. OPEC Basket daily values |
updated_at | string | ISO 8601 timestamp of the last update (UTC) |
type | string | Price type, e.g. "spot_price" |
unit | string | Unit of measure (e.g. "barrel") |
source | string | Data source identifier |
data_status | string | Freshness label β "current", or "stale" when the point is outside its series' expected cadence |
freshness | object | status, age_seconds, expected_max_age_seconds β how recent the point is |
stale | bool | Present and true only when the point is outside its series' cadence. Read this β see Stale prices |
age_days | number | Whole days since the point was last real (as_of). Present on every price |
stale_reason | string | Why the point is stale, e.g. "no_recent_data", "discontinued". Present when stale is true |
as_of | string | ISO 8601 moment the price was last a genuine reading from its source. On a carried-forward row it resolves back to the real print being copied, so a copy chain cannot make a dead series look fresh. age_days is measured from it |
collected_at | string | ISO 8601 timestamp of when we fetched and stored the row. On a fresh observation this is approximately as_of; on a carried-forward row it is later β the gap is how long the value has been carried |
synthetic | bool | true when the row is a carried-forward copy of an earlier price (served with source: "heartbeat") rather than a fresh source observation; false for genuine source readings |
changes | object | Price moves keyed by window (24h, 7d, 30d, 90d) β see Price changes below |
changes_omitted | object | Present when one or more change windows are withheld β reason, omitted_periods, message. It can appear alongside surviving changes windows |
instrument_disclosure | object | Present only on codes that are calendar-month average swaps rather than spot prices (SINGAPORE_JET_KEROSENE_USD, SINGAPORE_MOGAS_92_USD). Omitted from CSV output β see Swap disclosures |
metadata.discover | object | Optional and additive: { "endpoint", "description" } suggesting one endpoint your account has not called yet. Absent for most accounts and for every non-hinted request; safe to ignore |
metadata.source | string | Data source identifier |
metadata.source_description | string | Human-readable description of the data source |
Stale prices
A commodity code stays valid after its source stops publishing. When that happens the endpoint does not 404 β a polling client would see an error where the correct answer is "this is the last real value, and it is old." Instead the price is served with its age stated:
{
"status": "success",
"data": {
"price": 34.04,
"code": "CME_COAL_USD",
"created_at": "2020-03-27T12:00:00.000Z",
"stale": true,
"age_days": 2350,
"data_status": "stale",
"stale_reason": "discontinued",
"discontinued": true,
"as_of": "2020-03-27T12:00:00.000Z",
"changes_omitted": {
"reason": "measurement_older_than_window",
"omitted_periods": ["24h", "30d", "7d"],
"message": "Some price-change windows were omitted because the most recent observation is older than the window it would have been labelled with. See `as_of` for when this price was last real."
}
}
}
This is a real response for CME_COAL_USD (a discontinued futures series), captured 2026-09-02 and trimmed to the freshness fields β a good code to try this against yourself. A series that has merely paused publishing looks the same, with stale_reason: "no_recent_data" instead of "discontinued".
price alone is not enough to act on. Check stale (or data_status) on every response and decide what your system should do with an old number β that decision belongs to you, not to us. age_days and as_of tell you how old it is.
No changes block is returned for a stale price. Every comparison window is measured from the served point's own timestamp, so on a series that has stopped publishing a "24h change" would describe the last day that existed, not the last day β which reads as recent movement when there has been none.
Single code and multiple codes behave differently
This is the one asymmetry worth knowing about, because it changes which codes you get back:
| Request | A stale code is⦠|
|---|---|
by_code=CME_COAL_USD (one code) | returned, with stale: true and age_days |
by_code=CME_COAL_USD,BRENT_CRUDE_USD (several) | withheld from prices and named in missing |
With several codes, anything outside the freshness window is moved into a sibling missing array rather than sitting in prices where a dashboard would render it as live (response captured 2026-09-02; the counters drift daily):
{
"status": "success",
"data": {
"prices": [{ "code": "BRENT_CRUDE_USD", "price": 95.44, "stale": false }],
"missing": [
{
"code": "CME_COAL_USD",
"reason": "discontinued",
"discontinued": true,
"last_seen": "2025-12-19T00:00:18Z",
"days_stale": 257,
"message": "CME Coal Futures (Discontinued) has been discontinued and returned no current price."
}
]
}
}
reason is one of stale, discontinued, no_data (a known code that has never produced a price) or unknown. Two codes are enough to get this behaviour. The same information is also returned in an X-Missing-Codes response header, so CSV callers see it too.
Practical consequence: batching your codes into one request gives you the stale check for free. Anything that cannot be honestly served arrives in missing, with a reason, instead of as a number you have to validate yourself.
A single GET /v1/prices/latest accepts up to 20 comma-separated codes and counts as one request against your plan. A 21st code returns 400:
{
"status": "fail",
"data": {
"error": "Too many commodity codes requested (max: 20, requested: 21)"
}
}
So twenty codes fetched one at a time cost twenty requests and give you twenty prices you must each check; the same twenty in one call cost one request and hand you the problems already separated out.
Swap disclosures
Two codes are exchange-traded calendar-month average futures, not spot assessments: SINGAPORE_JET_KEROSENE_USD (NYMEX ch. 670) and SINGAPORE_MOGAS_92_USD (NYMEX ch. 720). They are stored and routed as type: "spot_price", so the response states what the number is in an instrument_disclosure object next to the price:
{
"status": "success",
"data": {
"price": 144.3,
"code": "SINGAPORE_JET_KEROSENE_USD",
"type": "spot_price",
"unit": "barrel",
"instrument_disclosure": {
"instrument": "calendar_month_average_swap",
"display_name": "Singapore Jet Kerosene (Platts) calendar-month average futures",
"underlying": "Platts Asia-Pacific Marketscan, Singapore Cargoes of Jet Kerosene",
"quote_basis": "contract_month_average",
"contract_reference": "NYMEX ch. 670 (Globex AKS)",
"settlement": "Settles to the arithmetic average of the Platts Singapore quotations for each business day the assessment is determined during the contract month. β¦",
"averaging": "Do not average this series to obtain a monthly average. Each point already references the whole contract month, so averaging them averages a moving blend of forward and realised fixings. The realised monthly average is a single number: the final settlement of that month's contract.",
"identity_caveat": "β¦",
"contract_month": "2026-10"
}
}
}
| Field | Description |
|---|---|
instrument | calendar_month_average_swap |
quote_basis | contract_month_average β each quote references a whole contract month, not the day you fetched it |
contract_reference | The exchange rulebook chapter and Globex code the series tracks |
settlement, averaging, identity_caveat | Prose explaining how the contract settles, why averaging the daily series does not give a monthly average, and what the series does not yet identify |
contract_month | YYYY-MM delivery month of this observation. Present only when the stored row records it; consecutive observations can reference different months |
The same object is attached to GET /v1/commodities/{code} for these codes, and the series endpoints (past_*, historical) carry it once per response under metadata.instrument_disclosures[] instead of on every row. CSV responses omit it.
Price changes
Every /v1/prices/latest response carries a changes object. There is no parameter to request it β it is always included, and it costs no additional API call.
Each window is an object:
| Field | Type | Description |
|---|---|---|
amount | number | Absolute move in the price currency (e.g. 0.72 = up $0.72) |
percent | number | Percentage move (e.g. 1.06 = up 1.06%) |
previous_price | number | The price compared against |
previous_timestamp | string | ISO 8601 timestamp of that earlier price |
measured_at | string | ISO 8601 timestamp of the current price |
span_hours | number | Hours actually spanned between the two points β may exceed the window label |
const pct = response.data.changes["24h"].percent; // 1.06
span_hours β read this before rendering a daily ticker
A 24h change legitimately spans longer than 24 hours when the market was closed. A Monday quote compares against Friday, so span_hours will read around 72.0. This is correct, not an error β we publish the span we actually measured rather than asserting the label, so you can decide whether to render it.
If you display a "today's move" figure, check span_hours before labelling it as such.
A window may be absent β check before reading it
When no honest comparison exists, the window is omitted rather than reported as zero. This happens when no comparison price was published within the permitted lookback (for 24h, a generous multi-day allowance that absorbs weekends and holidays, but not a weekly-published series), or when the change measurement itself is older than the window it would be labelled with. Omission is evaluated per window: valid windows remain under changes, while changes_omitted.omitted_periods names the windows withheld. The entire changes object is absent only when no window survives.
Omission is deliberate: a fabricated percentage is worse than no percentage. Sparsely-published commodities may never carry a 24h window at all.
Always guard before reading:
const c = response.data.changes?.["24h"];
if (c) console.log(`${c.percent}% over ${c.span_hours}h`);
Embedded devices should do the same β on Arduino, use an ArduinoJson filter to keep only the fields you need rather than growing the document buffer.
Windows available by plan
| Plan | Windows returned |
|---|---|
| Free, Developer, Exploration | 24h |
| Starter | 24h, 7d |
| Professional, Production | 24h, 7d, 30d |
| Scale, Reservoir, Enterprise | 24h, 7d, 30d, 90d |
Windows above your plan are omitted from the object β the response shape is otherwise unchanged.
Errors
| Code | Status | Description |
|---|---|---|
UNAUTHORIZED | 401 | Missing or invalid API key (returned as error.code) |
invalid_code | 400 | Unknown commodity code (returned under data with status: "fail") |
invalid_code body
An unknown code returns near-miss suggestions (up to 12 codes) and describes the first three in did_you_mean[] so you can recognise the commodity without a second call:
{
"status": "fail",
"data": {
"error": "invalid_code",
"message": "Code 'SINGAPORE_JET_KERO' not found. Did you mean: 'SINGAPORE_JET_KEROSENE_USD'? See /v1/commodities for all available codes.",
"suggestions": ["SINGAPORE_JET_KEROSENE_USD"],
"invalid_codes": ["SINGAPORE_JET_KERO"],
"did_you_mean": [
{
"code": "SINGAPORE_JET_KEROSENE_USD",
"name": "Singapore Jet Kerosene (Platts) Calendar-Month Average Futures",
"description": "Singapore Jet Kerosene (Platts) futures, NYMEX chapter 670. β¦",
"available": true
}
],
"all_codes_url": "https://www.oilpriceapi.com/v1/prices/codes"
}
}
| Field | Description |
|---|---|
did_you_mean[].code | A callable code |
did_you_mean[].name, description | Catalog name and description, when the code has a catalog entry |
did_you_mean[].available | Always present. true when the code appears in the default /v1/commodities listing |
did_you_mean[].unavailable_reason | Present only when available is false. The code is still callable; it is omitted from the default listing because it is not backed by data β GET /v1/commodities?include_unavailable=true lists it |
When a suggested code is not in the default listing the message says so and points at ?include_unavailable=true rather than at the bare listing. Spellings customers commonly type are accepted as aliases and never reach this error β for Western Canadian Select, WCS, CANADIAN_CRUDE, CANADIAN_CRUDE_USD, WESTERN_CANADIAN_SELECT and WESTERN_CANADIAN_SELECT_USD all resolve to WCS_CRUDE_USD.
Examples
# Get the default commodity (BRENT_CRUDE_USD)
curl "https://api.oilpriceapi.com/v1/prices/latest" \
-H "Authorization: Token YOUR_API_KEY"
# Get a specific commodity
curl "https://api.oilpriceapi.com/v1/prices/latest?by_code=WTI_USD" \
-H "Authorization: Token YOUR_API_KEY"
# Get multiple commodities (returned as a data.prices array)
curl "https://api.oilpriceapi.com/v1/prices/latest?by_code=WTI_USD,BRENT_CRUDE_USD" \
-H "Authorization: Token YOUR_API_KEY"
# Get the latest official OPEC Reference Basket price
curl "https://api.oilpriceapi.com/v1/prices/latest?by_code=OPEC_BASKET_USD" \
-H "Authorization: Token YOUR_API_KEY"
OPEC Basket responses include source-date fields:
{
"status": "success",
"data": {
"price": 69.33,
"formatted": "$69.33",
"currency": "USD",
"code": "OPEC_BASKET_USD",
"created_at": "2026-07-02T12:00:00.000Z",
"observed_at": "2026-07-02T12:00:00.000Z",
"source_date": "2026-07-02",
"type": "spot_price",
"unit": "barrel",
"source": "opec.org"
}
}
// JavaScript
async function getLatestPrice() {
const response = await fetch(
"https://api.oilpriceapi.com/v1/prices/latest?by_code=WTI_USD",
{
headers: {
Authorization: "Token YOUR_API_KEY",
},
},
);
const data = await response.json();
// For a single code, the price object is directly in data.data
console.log(`Current price: ${data.data.formatted}`); // "$68.58"
console.log(`Numeric value: ${data.data.price}`); // 68.58
console.log(`24h change: ${data.data.changes["24h"].percent}%`);
console.log(`Source: ${data.data.metadata.source_description}`);
return data.data.price;
}
# Python
import requests
def get_latest_price(commodity_code='WTI_USD'):
url = 'https://api.oilpriceapi.com/v1/prices/latest'
headers = {'Authorization': 'Token YOUR_API_KEY'}
params = {'by_code': commodity_code}
response = requests.get(url, headers=headers, params=params)
data = response.json()
price = data['data']['price']
formatted = data['data']['formatted']
print(f"Current {commodity_code}: {formatted}")
return price
# Get multiple commodities. With several codes, prices are in data['prices']
def get_multiple_prices(codes=['WTI_USD', 'BRENT_CRUDE_USD', 'NATURAL_GAS_USD']):
url = 'https://api.oilpriceapi.com/v1/prices/latest'
headers = {'Authorization': 'Token YOUR_API_KEY'}
params = {'by_code': ','.join(codes)}
response = requests.get(url, headers=headers, params=params)
prices = response.json()['data']['prices'] # a list, not keyed by code
return {p['code']: p for p in prices}
# Ruby
require 'net/http'
require 'json'
require 'uri'
def get_latest_price(commodity_code = 'WTI_USD')
uri = URI("https://api.oilpriceapi.com/v1/prices/latest?by_code=#{commodity_code}")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
request = Net::HTTP::Get.new(uri)
request['Authorization'] = 'Token YOUR_API_KEY'
response = http.request(request)
data = JSON.parse(response.body)
price = data['data']['price']
puts "Current #{commodity_code}: $#{price}"
price
end
Conditional requests
Most polling returns a price you already have. Across all customers, a request is served roughly 200 times for every new value published on the busiest codes, and far more than that on slower ones. Conditional requests let you stop paying for those.
Every response carries validators:
etag: W/"e89ed6a199b9e26d3389cd3e2d5e0464"
last-modified: Tue, 25 Aug 2026 10:04:19 GMT
Send the ETag back on your next call and you get 304 Not Modified with an empty body if nothing has changed:
curl -H "Authorization: Token YOUR_KEY" \
-H 'If-None-Match: W/"e89ed6a199b9e26d3389cd3e2d5e0464"' \
"https://api.oilpriceapi.com/v1/prices/latest?by_code=BRENT_CRUDE_USD"
A 304 does not count against your request quota. It transferred no data, so it is not charged. A conditional request that misses β because the price did change β returns a normal 200 with the new body and is charged like any other call.
It still consumes one slot in the 60-per-60-second rate limit, so this raises how much you can poll within your plan, not how fast.
Practically, this means you can poll at whatever interval your application needs and pay only for the values that actually moved. A display refreshing every minute against a benchmark that settles once a day costs you one charged request a day, not 1,440.
Last-Modified and If-Modified-Since work the same way if that fits your client better.
Rate Limits
All plans share the same request rate limit of 60 requests per rolling 60-second window per API key. Plans differ by request quota, not by request rate β and the quota window differs too: the Free plan is metered per day, the paid plans per month. Bursts within the window are fine. See Rate Limiting for details.
| Plan | Request quota | Request Rate Limit |
|---|---|---|
| Free | 50 per day | 60 per rolling 60s |
| Developer | 10,000 per month | 60 per rolling 60s |
| Starter | 50,000 per month | 60 per rolling 60s |
| Professional | 100,000 per month | 60 per rolling 60s |
| Scale | 1,000,000 per month | 60 per rolling 60s |
Rate limit headers are included in responses. X-RateLimit-Limit / -Remaining / -Used track your monthly quota:
X-RateLimit-Limit: 10000
X-RateLimit-Remaining: 9876
X-RateLimit-Reset: 1785542399
Best Practices
- Cache responses: Use the response
freshnessblock and source timestamps to decide when to refetch; cadence varies by source, market hours, dataset, and plan - Request specific commodities: Use
by_codewhen possible, comma-separated for several at once - Handle rate limits: Check
X-RateLimit-*headers and implement backoff
Related Endpoints
- Historical Prices - Past price data
- Commodities List - Available commodity codes
- WebSocket Transport - Availability depends on account entitlement