If you are building a TMS, a freight broker platform, or any logistics application that moves LTL shipments, at some point you need to generate a Bill of Lading programmatically. Doing it with a Word template and manual data entry works fine when you are shipping 10 loads a week. At 100 loads a week it becomes a full-time job. At 1,000 loads a week it becomes an operational crisis.
This guide walks through exactly what data a BOL requires, how to structure a REST API request that generates one, what the response looks like, and the practical Python code to wire it into your application. We will also cover the mistakes that most developers make on their first implementation - mistakes that produce BOLs carriers refuse to accept.
Before touching code, it helps to understand what a BOL does legally. It serves three simultaneous functions: it is a contract of carriage between the shipper and carrier, a receipt of goods confirming the carrier took possession, and (if negotiable) a document of title that can be transferred to transfer ownership of the freight. Most domestic LTL BOLs are straight (non-negotiable) BOLs, meaning title does not transfer - they are primarily a shipping contract and receipt.
An order BOL (also called a negotiable BOL) is used when ownership of the goods needs to transfer while the freight is in transit - this is common in international trade and commodity shipments financed by letters of credit. For most domestic LTL broker and 3PL use cases, you are generating straight BOLs.
The VICS BOL standard (Voluntary Interindustry Commerce Standards) defines the canonical field set for domestic BOLs. Here is what you need to collect before making a single API call:
NMFC class accuracy matters more than most shippers realize. If you put class 70 on the BOL but the freight actually classifies as class 92.5, the carrier will reclassify it at the destination terminal and invoice the shipper for the difference - plus a reclassification fee of $25-75 per shipment. For high-volume shippers this adds up fast. See our guide on how to calculate freight class using density to get this right before generating your BOL.
A well-designed BOL generation API accepts a JSON payload representing a single shipment and returns either a PDF binary or a structured JSON response containing the document URL. Here is a complete example payload covering all required and commonly used optional fields:
{
"bol_type": "straight",
"shipper": {
"company": "Acme Manufacturing LLC",
"address": "1400 Industrial Pkwy",
"city": "Cincinnati",
"state": "OH",
"zip": "45211",
"contact": "John Rivera",
"phone": "513-555-0192",
"reference": "PO-2026-8841"
},
"consignee": {
"company": "Midwest Distribution Co",
"address": "8200 Commerce Dr",
"city": "Indianapolis",
"state": "IN",
"zip": "46219",
"contact": "Sarah Chen",
"phone": "317-555-0447",
"reference": "DC-RCVG-4421"
},
"carrier": {
"scac": "EXLA",
"pro_number": null,
"pickup_date": "2026-03-24"
},
"commodities": [
{
"description": "Auto Parts - Machined Aluminum Brackets",
"nmfc_item": "150900",
"freight_class": "85",
"pieces": 8,
"packaging": "PLT",
"weight_lbs": 1640,
"dimensions": {
"length_in": 48,
"width_in": 40,
"height_in": 52
},
"hazmat": false
}
],
"accessorials": ["liftgate_delivery", "appointment_required"],
"special_instructions": "Delivery window: Mon-Fri 8am-3pm only. Contact receiver 24hrs in advance.",
"declared_value": null,
"bill_charges_to": "shipper",
"payment_terms": "prepaid"
}
Most document generation APIs offer two response modes. The first is a PDF binary response with Content-Type: application/pdf - the raw PDF bytes returned directly in the response body. This is simple to implement but requires the caller to handle storage. The second is a JSON response with a document URL:
{
"success": true,
"bol_id": "bol_01HVMK2X9P4QR8YZABCD",
"pro_number": null,
"scac": "EXLA",
"document_url": "https://docs.freightdoc.io/generated/bol_01HVMK2X9P4QR8YZABCD.pdf",
"expires_at": "2026-04-24T00:00:00Z",
"page_count": 1,
"generated_at": "2026-03-24T14:22:31Z"
}
The JSON-with-URL approach is generally better for integrations because you get a stable URL to store in your TMS, the PDF is available for re-download without re-generating, and you can pass the URL directly to email delivery systems.
Here is a clean Python function that generates a BOL and returns the document URL. It handles authentication, request construction, and basic error handling:
import requests
import json
from datetime import date
FREIGHTDOC_API_KEY = "fd_live_your_api_key_here"
FREIGHTDOC_BASE_URL = "https://api.freightdoc.io/v1"
def generate_bol(shipment_data: dict) -> dict:
"""
Generate a Bill of Lading via FreightDoc API.
Returns dict with bol_id and document_url on success.
Raises ValueError for validation errors, RuntimeError for API failures.
"""
headers = {
"Authorization": f"Bearer {FREIGHTDOC_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json"
}
try:
response = requests.post(
f"{FREIGHTDOC_BASE_URL}/documents/bol",
headers=headers,
json=shipment_data,
timeout=30
)
except requests.Timeout:
raise RuntimeError("BOL generation timed out after 30s")
except requests.ConnectionError as e:
raise RuntimeError(f"Connection failed: {e}")
if response.status_code == 422:
errors = response.json().get("errors", [])
raise ValueError(f"Validation failed: {'; '.join(errors)}")
if response.status_code == 400:
msg = response.json().get("message", "Bad request")
raise ValueError(f"Invalid request: {msg}")
if not response.ok:
raise RuntimeError(
f"API error {response.status_code}: {response.text[:200]}"
)
result = response.json()
return {
"bol_id": result["bol_id"],
"document_url": result["document_url"],
"expires_at": result["expires_at"]
}
# Example usage
shipment = {
"bol_type": "straight",
"shipper": {
"company": "Acme Manufacturing LLC",
"address": "1400 Industrial Pkwy",
"city": "Cincinnati",
"state": "OH",
"zip": "45211",
"contact": "John Rivera",
"phone": "513-555-0192",
"reference": "PO-2026-8841"
},
"consignee": {
"company": "Midwest Distribution Co",
"address": "8200 Commerce Dr",
"city": "Indianapolis",
"state": "IN",
"zip": "46219",
"contact": "Sarah Chen",
"phone": "317-555-0447"
},
"carrier": {
"scac": "EXLA",
"pro_number": None,
"pickup_date": str(date.today())
},
"commodities": [{
"description": "Auto Parts - Machined Aluminum Brackets",
"nmfc_item": "150900",
"freight_class": "85",
"pieces": 8,
"packaging": "PLT",
"weight_lbs": 1640,
"dimensions": {"length_in": 48, "width_in": 40, "height_in": 52},
"hazmat": False
}],
"accessorials": [],
"bill_charges_to": "shipper",
"payment_terms": "prepaid"
}
try:
bol = generate_bol(shipment)
print(f"BOL generated: {bol['bol_id']}")
print(f"Download URL: {bol['document_url']}")
except ValueError as e:
print(f"Validation error - fix the data: {e}")
except RuntimeError as e:
print(f"API failure - retry or escalate: {e}")
BOL generation APIs fail in predictable ways. Here are the errors you will encounter and what causes them:
These are your fault. Common causes: freight class not in the valid set (50, 55, 60, 65, 70, 77.5, 85, 92.5, 100, 110, 125, 150, 175, 200, 250, 300, 400, 500), SCAC code not in the NMFTA carrier database, zip code format wrong (must be 5-digit or ZIP+4), weight zero or negative, piece count zero.
SCAC codes are four characters, uppercase, letters only. Common mistake: including a trailing space or passing the carrier's name instead of their SCAC. "Estes Express" is not a SCAC. "EXLA" is. Maintain a SCAC lookup table in your application rather than asking users to type it.
LTL shipments max out at 20,000 lbs per shipment (some carriers go to 22,000). Above that you are looking at volume LTL or FTL. If your API call includes a weight above the carrier's limit, expect a 400 with a message about weight thresholds.
Street address cannot be a PO Box for pickup or delivery points. City and state must match the zip code - a zip code validator in your front-end form saves a lot of these failures. State must be a two-letter US state code, not "Ohio" or "oh".
If you are building your own BOL generator rather than using an API, the two most common Python PDF libraries are WeasyPrint and ReportLab. WeasyPrint renders HTML/CSS to PDF, which makes it easy to maintain BOL templates as HTML files and get consistent output. ReportLab gives you lower-level control over placement, which matters for precise BOL format compliance when a carrier has strict layout requirements (some carriers, like Old Dominion, have specific field positioning requirements for their branded BOL forms).
For most use cases, an HTML template rendered by WeasyPrint produces professional, carrier-acceptable BOLs. The key pitfalls: fonts must be embedded (do not rely on system fonts on the server), the BOL must be letter-sized (8.5x11), and the PRO number barcode - if included - must be Code 128 or Code 39 format that carrier scanners can read.
Once you have a PDF, you need a delivery strategy. The common approaches:
Retention matters. BOLs should be retained for a minimum of 3 years per FMCSA record-keeping rules. Build your storage strategy around that requirement from day one - migrating 50,000 PDFs out of local disk storage two years later is not fun.
After watching dozens of teams integrate BOL generation into their platforms, the same mistakes appear repeatedly:
Not validating freight class against the NMFC table. Accepting free-text input for freight class means someone will type "class 70" instead of "70", or "7O" (letter O, not zero), and the API call will fail at the worst possible moment. Enforce a select/dropdown with the 18 valid classes.
Using the consignee's billing address instead of the delivery address. A surprising number of BOL generation bugs trace back to address book issues in the calling system. The ship-to address on a BOL must be a physical street address that a truck can pull into - not a corporate billing address.
Omitting PRO number handling. Some carriers require the PRO number on the BOL at generation time. Others assign it at pickup. Build your system to handle both cases: accept an optional PRO number field and if the carrier requires it upfront, make it required when that carrier's SCAC is selected.
Generating BOLs without dimensions. Legacy systems often skip dimensions because older BOL formats did not require them. Modern LTL pricing is increasingly density-based. Carriers like FedEx Freight and Old Dominion will measure your freight at the terminal if dimensions are missing and charge based on their measurement - which is almost always less favorable than your actual dimensions. Always include length, width, and height.
Not handling the accessorials list as an array. Developers sometimes pass accessorials as a comma-separated string instead of a JSON array. This causes silent failures where the API accepts the request but ignores the accessorial requirements, resulting in surprise accessorial charges on the freight invoice.
For a complete checklist of what documentation LTL shipments require beyond the BOL, see our LTL Shipment Documentation Checklist. And if you want to understand exactly how to calculate the freight class that goes on line 3 of your BOL, see our deep dive on NMFC density-based freight classification.
Ready to stop writing BOL generation code from scratch? FreightDoc handles the full document generation workflow - BOL, rate confirmation, and carrier packets - via a single REST API that is production-ready in under a day.
FreightDoc generates BOL, rate confirmations, carrier packets, and customs docs via API - in under 2 seconds.
Join the Waitlist