Skip to main content

JSONCargo

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 requests library — pip install requests (Mac/Linux) or py -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.

1

Set up authentication

JSONCargo uses API key authentication. You pass your key as a header on every request:

Python
import 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")

2

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:

CarrierAPI Parameter
MaerskMAERSK
MSCMSC
CMA CGMCMA_CGM
Hapag-LloydHAPAG_LLOYD
EvergreenEVERGREEN
COSCOCOSCO
HMM (Hyundai Merchant Marine)HMM
ONE (Ocean Network Express)ONE
ZIMZIM
Yang MingYANG_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 shipment

3

Fetch 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.


4

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 data

Handling 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 None

What the response data looks like

Each container returns a rich set of fields. The ones you'll use most:

FieldWhat it tells you
container_statusCurrent status, e.g. "Vessel departed"
last_locationMost recent port or terminal
eta_final_destinationETA at final destination
current_vessel_nameThe vessel currently carrying the container
current_voyage_numberCurrent voyage number
last_vessel_name / last_voyage_numberPrevious leg vessel and voyage
loading_port / discharging_portOrigin and destination ports
atd_originActual time of departure from origin
timestamp_of_last_locationWhen the last location update was recorded
bill_of_ladingConfirms 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.