How to Track a Shipping Container by BL Number Using Python
Tracking a Shipping Container by BL Number cause a lot of frustration and manual work. Most carrier websites want a container number. Your logistics team gives you a BOL. This guide shows you how to bridge that gap programmatically using the JSONCargo container API with Python.
What's a Bill of Lading?
Quick context before we jump in. A Bill of Lading (BOL or B/L) is the document that a shipping line issues when they take custody of cargo. It covers the entire shipment — which might be split across multiple containers. So one BOL can map to anywhere from one container to dozens of them.
That's the reason you need two API calls to do this properly: first to resolve the BOL to a list of container numbers, then to fetch tracking details for each container.
What we're building using BL number?
The flow looks like this:
We'll build this as a clean Python function you can drop into any project.
Prerequisites
You'll need:
- Python 3.7+
- The
requestslibrary —pip install requests(Mac/Linux) orpy -m pip install requests(Windows) - A JSONCargo API key — grab one at jsoncargo.com/pricing. There's a free trial, so no need to commit before testing.
Set up authentication
JSONCargo uses API key authentication. You pass your key as a header on every request:
Pythonimport requests
API_KEY = "your_api_key_here"
BASE_URL = "https://api.jsoncargo.com/api/v1"
headers = {
"x-api-key": API_KEY
}Keep your API key out of source code — use an environment variable in real projects:
import os
API_KEY = os.environ.get("JSONCARGO_API_KEY")Resolve the BOL to container numbers
The first endpoint takes a BOL number and a shipping line, and returns all container numbers associated with that shipment.
def get_containers_from_bol(bol_number, shipping_line):
url = f"{BASE_URL}/containers/bol/{bol_number}"
params = {"shipping_line": shipping_line}
response = requests.get(url, headers=headers, params=params)
response.raise_for_status()
data = response.json()["data"]
print(f"Found {data['associated_containers']} container(s) on BOL {bol_number}")
return data["associated_container_numbers"]The shipping_line parameter is required. JSONCargo uses specific internal names for each carrier:
| Carrier | API Parameter |
|---|---|
| Maersk | MAERSK |
| MSC | MSC |
| CMA CGM | CMA_CGM |
| Hapag-Lloyd | HAPAG_LLOYD |
| Evergreen | EVERGREEN |
| COSCO | COSCO |
| HMM (Hyundai Merchant Marine) | HMM |
| ONE (Ocean Network Express) | ONE |
| ZIM | ZIM |
| Yang Ming | YANG_MING |
| PIL (Pacific International Lines) | PIL |
A quick test with a real BOL:
containers = get_containers_from_bol("SZPE72846000", "HMM")
print(containers)
# ['ROEU8622402', 'CLKU5004260', 'GAOU6162340', 'TGBU6353192', 'CAIU9933760',
# 'KOCU4503822', 'KOCU5067657', 'KOCU4904240', 'HMMU6053862', 'HDMU6653051',
# 'HDMU6836237', 'HMMU6541677', 'KOCU5082841', 'HMMU6668297', 'KOCU4771471',
# 'KOCU4839231']
# → 16 containers found on this shipmentFetch tracking details for each container
Now that you have the container numbers, call Endpoint 1 for each one. This gives you the full tracking picture: current location, ETA, vessel name, port history, and more.
def get_container_details(container_number, shipping_line=None):
url = f"{BASE_URL}/containers/{container_number}"
params = {}
if shipping_line:
params["shipping_line"] = shipping_line
response = requests.get(url, headers=headers, params=params)
response.raise_for_status()
return response.json()["data"]The shipping_line parameter is only required for containers with shared prefixes (where the same four-letter prefix is used by multiple carriers). For most standard containers it's optional — but if you already have it from the BOL lookup, include it anyway. It doesn't hurt and avoids potential ambiguity.
Putting it all together
Here's a complete function that takes a BOL and returns full tracking data for every container on the shipment:
import requests
import os
import time
API_KEY = os.environ.get("JSONCARGO_API_KEY")
BASE_URL = "https://api.jsoncargo.com/api/v1"
headers = {"x-api-key": API_KEY}
def track_shipment_by_bol(bol_number, shipping_line):
# Step 1: Get container numbers from BOL
bol_url = f"{BASE_URL}/containers/bol/{bol_number}"
bol_response = requests.get(
bol_url,
headers=headers,
params={"shipping_line": shipping_line}
)
bol_response.raise_for_status()
bol_data = bol_response.json()["data"]
container_numbers = bol_data["associated_container_numbers"]
print(f"BOL {bol_number} — {len(container_numbers)} container(s) found")
# Step 2: Fetch tracking details for each container
results = []
for container_id in container_numbers:
container_url = f"{BASE_URL}/containers/{container_id}"
container_response = requests.get(
container_url,
headers=headers
)
if container_response.status_code == 200:
results.append(container_response.json()["data"])
else:
print(f"Warning: Could not fetch data for {container_id} "
f"(status {container_response.status_code})")
# Be polite to the API — small delay between calls
time.sleep(0.2)
return results
# Usage
shipment = track_shipment_by_bol("SZPE72846000", "HMM")
for container in shipment:
print(f"""
Container: {container['container_id']}
Status: {container['container_status']}
Location: {container['last_location']}
ETA: {container['eta_final_destination']}
Vessel: {container['current_vessel_name']}
""")
# Example output:
#
# BOL SZPE72846000 — 16 container(s) found
#
# Container: KOCU4503822
# Status: Empty returned by Truck
# Location: Southampton United Kingdom
# ETA: 2026-02-06
# Vessel: N/A
#
# Container: HMMU6541677
# Status: Departed by Vessel
# Location: Yantian China
# ETA: 2026-06-03
# Vessel: N/A
#
# ... and so on for all 11 containers that returned dataHandling errors properly
A few things that come up in practice:
400 — Invalid request
The most common beginner mistake: the BOL number format is wrong, the shipping_line parameter is missing, or the carrier name doesn't exactly match one of the supported values (it's case-sensitive).
404 — Container not found
Either the container number is wrong, or the shipping line isn't supported yet. JSONCargo currently covers 11 major carriers — check the table above.
404 — Prefix not found
The container prefix isn't in JSONCargo's database yet. This can happen with newer or less common leasing company prefixes. Their support team can add these — reach out to support@jsoncargo.com.
429 — Rate limit exceeded
You've hit your monthly request cap. The time.sleep(0.2) in the loop above helps avoid bursting through your quota on large shipments, but if you're tracking frequently, keep an eye on your usage. You can check it programmatically:
def check_api_usage():
response = requests.get(
f"{BASE_URL}/api_key/stats",
headers=headers
)
data = response.json()["data"]
print(f"Plan: {data['plan']}")
print(f"Used: {data['requests_made']} / {data['requests_total']}")
print(f"Remaining: {data['requests_available']}")
check_api_usage()Here's a more robust version of the container fetch that handles all these cases explicitly:
def get_container_safe(container_id, shipping_line):
url = f"{BASE_URL}/containers/{container_id}"
try:
response = requests.get(
url,
headers=headers,
params={"shipping_line": shipping_line},
timeout=10
)
if response.status_code == 200:
return response.json()["data"]
elif response.status_code == 400:
print(f"Invalid request for {container_id} — check BOL format "
"and that shipping_line matches a supported carrier name exactly")
return None
elif response.status_code == 404:
print(f"Container {container_id} not found")
return None
elif response.status_code == 429:
print("Rate limit reached — check your usage with the stats endpoint")
return None
else:
print(f"Unexpected error {response.status_code} for {container_id}")
return None
except requests.exceptions.Timeout:
print(f"Request timed out for {container_id}")
return NoneWhat the response data looks like
Each container returns a rich set of fields. The ones you'll use most:
| Field | What it tells you |
|---|---|
container_status | Current status, e.g. "Vessel departed" |
last_location | Most recent port or terminal |
eta_final_destination | ETA at final destination |
current_vessel_name | The vessel currently carrying the container |
current_voyage_number | Current voyage number |
last_vessel_name / last_voyage_number | Previous leg vessel and voyage |
loading_port / discharging_port | Origin and destination ports |
atd_origin | Actual time of departure from origin |
timestamp_of_last_location | When the last location update was recorded |
bill_of_lading | Confirms which BOL this container belongs to |
What to do next
The script above is the foundation. From here, a few natural directions depending on what you're building:
- Store results in a database — write the tracking data to PostgreSQL after each poll so you have a history of status changes over time, not just a snapshot.
- Schedule regular polls — run the script on a cron job to check your active shipments every few hours. Just keep your request count in mind relative to your plan.
- Build a simple dashboard — pipe the data into Metabase or a Flask frontend if you need visibility for a non-technical team.
The full JSONCargo API documentation is at jsoncargo.com/documentation-api — vessel tracking, port lookups, and terminal data are all available alongside container tracking if you want to enrich your pipeline further.
The early code samples use raise_for_status() for brevity. In production, swap those out for the explicit status code handling shown in the error section — it'll save you debugging time later.