Bunker price API in Python: Singapore VLSFO, MGO and HFO 380
By OilPriceAPI · Live source and API reference checked November 5, 2026
VLSFO, marine gas oil and HFO 380 are different bunker grades. Compare the same port, currency, unit and source day before using a spread in procurement or reporting. The Singapore codes below had matching source dates when checked on November 5; the response may differ when you run the script.
Fetch three grades in one request
Set OILPRICEAPI_KEY, save this as bunker_prices.py, then run python3 bunker_prices.py. The documented /v1/prices/latest batch response puts available records under data.prices and may list withheld codes under data.missing. Marine access depends on your account and source rights.
import json
import math
import os
from datetime import datetime, timezone
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
codes = ("VLSFO_SGSIN_USD", "MGO_05S_SGSIN_USD", "HFO_380_SGSIN_USD")
url = "https://api.oilpriceapi.com/v1/prices/latest?by_code=" + ",".join(codes)
key = os.environ.get("OILPRICEAPI_KEY")
if not key:
raise SystemExit("Set OILPRICEAPI_KEY before running this example")
request = Request(url, headers={"Authorization": "Token " + key})
try:
with urlopen(request, timeout=10) as response:
payload = json.load(response)
except HTTPError as error:
if error.code in (401, 403):
raise SystemExit("Check your API key and marine-fuel access")
if error.code in (402, 429):
raise SystemExit("Quota or rate limit reached; check account limits")
raise SystemExit(f"Bunker request failed with HTTP {error.code}") from error
except URLError:
raise SystemExit("Bunker request unavailable; retry instead of using old data")
data = payload.get("data") if isinstance(payload, dict) else None
rows = data.get("prices") if isinstance(data, dict) else None
if not isinstance(payload, dict) or payload.get("status") != "success" or not isinstance(rows, list):
raise SystemExit("No usable batch response; do not substitute zero prices")
missing = data.get("missing") or []
if missing:
print("Unavailable codes:", missing)
by_code = {}
duplicate_codes = set()
for row in rows:
if not isinstance(row, dict) or row.get("code") not in codes:
continue
code = row["code"]
if code in by_code or code in duplicate_codes:
by_code.pop(code, None)
duplicate_codes.add(code)
continue
price = row.get("price")
source_time = row.get("as_of")
if (isinstance(price, bool) or not isinstance(price, (int, float))
or not math.isfinite(price) or price < 0
or row.get("currency") != "USD" or row.get("unit") != "metric_ton"
or not isinstance(source_time, str) or not source_time
or row.get("stale") or row.get("data_status") == "stale"
or row.get("dataStatus") == "stale"):
continue
try:
observed_at = datetime.fromisoformat(source_time.replace("Z", "+00:00"))
except ValueError:
continue
age_seconds = (datetime.now(timezone.utc) - observed_at).total_seconds() if observed_at.tzinfo else None
if age_seconds is None or age_seconds < 0 or age_seconds > 7 * 86400:
continue
by_code[code] = (price, observed_at)
for code in codes:
if code in by_code:
price, observed_at = by_code[code]
print(code, price, "USD/metric ton as of", observed_at.isoformat())
else:
print(code, "unavailable or stale; check source and plan")
if all(code in by_code for code in codes):
dates = {by_code[code][1].date() for code in codes}
if len(dates) == 1:
vlsfo = by_code["VLSFO_SGSIN_USD"][0]
mgo = by_code["MGO_05S_SGSIN_USD"][0]
hfo = by_code["HFO_380_SGSIN_USD"][0]
print("MGO minus VLSFO:", round(mgo - vlsfo, 2), "USD/metric ton")
print("VLSFO minus HFO 380:", round(vlsfo - hfo, 2), "USD/metric ton")
else:
print("Spreads withheld: grades have different source dates")
Each grade is validated separately. A 200 response can omit a grade or contain an old observation; the script prints that condition and withholds the spreads. It never mixes USD per metric ton with a barrel quote or different source dates.
Read the source before quoting
See the latest-prices API reference for batch response shape and stale-code behavior. The values are source observations, not a firm delivered bunker quote; confirm the applicable grade, port and commercial terms separately.