OilPriceAPI guides

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.

Explore Singapore prices