Distance-based quoting for freight brokers using a routing API
Geocode origin and destination as they're typed, pull road distance and drive time via truck-mode routing, and return a rate in seconds — no call-back.
Every freight broker knows the problem. A shipper calls with a load that needs to move Tuesday. Someone on the brokerage side opens a spreadsheet, scans a lane-rate table, phones or emails two or three carriers to check availability, and calls the shipper back forty minutes later with a number that may or may not still be competitive. By the time the call-back happens, the shipper has already phoned three other brokers.
The fix is not a new TMS. It is two API calls and a rate-card lookup that you already own. Geocode both ends the moment the addresses are typed. Hit the Routing API in truck mode for road distance and estimated drive time. Apply your own rate-per-mile bands. Return a quote before the shipper finishes reading the confirmation email.
This post walks through that architecture end to end — REST examples, the matrix pattern for multi-carrier scenarios, the caching layer that makes it free to run at volume, and the failure modes that will catch you out if you skip them.
Why "as the crow flies" is not good enough
Air-line distance between two coordinates is the wrong input for a freight rate. A shipper asking for a quote from a warehouse in Chicago to a distribution centre near Denver expects a price built from road miles, not great-circle distance. The gap between the two is not trivial — mountain corridors, river crossings, and urban routing constraints can push actual road distance 15-25% above straight-line distance for certain lanes. Quoting from air-line distance either erodes your margin on the lanes where road adds the most, or makes you uncompetitive on flat, direct interstate lanes where your air-line calculation accidentally over-estimates.
Truck mode adds another layer. A standard car routing profile optimises for speed on roads any passenger vehicle can use. A truck routing profile respects weight limits on bridges, overhead clearance restrictions under overpasses, hazmat exclusions, and the practical reality that a 53-foot trailer cannot navigate a downtown rat run that a sedan can. A car-mode distance that routes a truck through downtown Chicago instead of around the bypass does not produce a usable quote.
The CSV2GEO Routing API has five modes, including TRUCK with truck-specific attributes. The examples in this post use truck mode throughout.
The basic quote flow — two calls
The minimal viable quoting path is two HTTP calls: one to forward-geocode each end (or one batched call for both), and one to the route endpoint.
Geocoding the two ends
Most freight quoting UIs accept free-text address input from the shipper. Turn that text into coordinates immediately. A parallel geocode for both ends keeps latency out of the user's experience.
# Origin
curl -G "https://csv2geo.com/api/v1/geocode" \
--data-urlencode "q=2200 S Wolf Rd, Des Plaines, IL 60018" \
--data-urlencode "api_key=$CSV2GEO_KEY"
# Destination (run in parallel)
curl -G "https://csv2geo.com/api/v1/geocode" \
--data-urlencode "q=12300 E 39th Ave, Denver, CO 80239" \
--data-urlencode "api_key=$CSV2GEO_KEY"Both calls return a confidence score alongside the coordinates. Any result below 0.7 should surface a "please confirm this address" prompt before you build a quote from it. A mis-geocoded origin or destination produces a distance that is defensibly wrong — the API did exactly what you asked, but you asked with a bad address.
In Python, fire both in parallel:
import concurrent.futures
import os
import requests
API = "https://csv2geo.com/api/v1"
KEY = os.environ["CSV2GEO_KEY"]
def geocode(address):
r = requests.get(
f"{API}/geocode",
params={"q": address, "api_key": KEY},
timeout=15,
)
r.raise_for_status()
results = r.json().get("results", [])
if not results:
return None
best = results[0]
return {
"lat": best["lat"],
"lng": best["lng"],
"confidence": best.get("confidence", 0),
"formatted": best.get("formatted_address", address),
}
origin_addr = "2200 S Wolf Rd, Des Plaines, IL 60018"
dest_addr = "12300 E 39th Ave, Denver, CO 80239"
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as ex:
fut_o = ex.submit(geocode, origin_addr)
fut_d = ex.submit(geocode, dest_addr)
origin = fut_o.result()
dest = fut_d.result()
if origin["confidence"] < 0.7 or dest["confidence"] < 0.7:
raise ValueError("Low-confidence geocode — confirm address before quoting")Calling the route endpoint in truck mode
With both coordinates in hand, a single route call returns road distance and estimated drive time:
curl -G "https://csv2geo.com/api/v1/route" \
--data-urlencode "origin=41.9994,-87.9031" \
--data-urlencode "destination=39.7711,-104.8408" \
--data-urlencode "mode=TRUCK" \
--data-urlencode "api_key=$CSV2GEO_KEY"The response includes distance_miles (or distance_km, depending on your units parameter), duration_seconds, and the route geometry if you request it. The pricing logic that converts distance to a rate is entirely yours — the API gives you the road distance and time; your rate card gives you the dollar figure.
def get_route(origin_lat, origin_lng, dest_lat, dest_lng, mode="TRUCK"):
r = requests.get(
f"{API}/route",
params={
"origin": f"{origin_lat},{origin_lng}",
"destination": f"{dest_lat},{dest_lng}",
"mode": mode,
"units": "imperial",
"api_key": KEY,
},
timeout=20,
)
r.raise_for_status()
data = r.json()
return {
"miles": data["distance_miles"],
"duration_hours": data["duration_seconds"] / 3600,
}
route = get_route(origin["lat"], origin["lng"], dest["lat"], dest["lng"])And then your own rate logic:
def compute_quote(miles, rate_per_mile, minimum_charge=250.0):
raw = miles * rate_per_mile
return max(raw, minimum_charge)
quote = compute_quote(route["miles"], rate_per_mile=3.20)
print(f"Distance: {route['miles']:.1f} mi | Drive time: {route['duration_hours']:.1f} h | Quote: ${quote:,.2f}")The shipper gets a number in the time it takes your server to make two HTTP calls. The call-back becomes optional.
The matrix pattern — one origin, many carriers or lanes
A quoting scenario that comes up constantly in 3PL operations: you have one shipment origin but you want to price it against multiple possible destinations or multiple carrier hubs simultaneously. Running serial route calls is slow. The Routing Matrix endpoint handles N×M pairs in one call.
import json
def routing_matrix(origins, destinations, mode="TRUCK"):
"""
origins, destinations: lists of (lat, lng) tuples
returns an N×M list of {miles, duration_hours} dicts
"""
origins_str = "|".join(f"{lat},{lng}" for lat, lng in origins)
dests_str = "|".join(f"{lat},{lng}" for lat, lng in destinations)
r = requests.get(
f"{API}/matrix",
params={
"origins": origins_str,
"destinations": dests_str,
"mode": mode,
"units": "imperial",
"api_key": KEY,
},
timeout=30,
)
r.raise_for_status()
rows = r.json()["matrix"]
result = []
for row in rows:
result.append([
{"miles": cell["distance_miles"],
"duration_hours": cell["duration_seconds"] / 3600}
for cell in row
])
return resultA practical example: one shipper origin against five candidate carrier pickup hubs.
shipper_origin = [(41.9994, -87.9031)] # Des Plaines, IL
carrier_hubs = [
(39.7711, -104.8408), # Denver, CO
(41.2565, -95.9345), # Omaha, NE
(35.4676, -97.5164), # Oklahoma City, OK
(44.9778, -93.2650), # Minneapolis, MN
(37.6879, -97.3442), # Wichita, KS
]
matrix = routing_matrix(shipper_origin, carrier_hubs)
rates_per_mile = [3.20, 2.95, 3.05, 3.10, 2.88] # your rate card per hub
for i, (hub, rpm) in enumerate(zip(carrier_hubs, rates_per_mile)):
cell = matrix[0][i]
q = compute_quote(cell["miles"], rpm)
print(f"Hub {i+1}: {cell['miles']:.0f} mi, {cell['duration_hours']:.1f} h → ${q:,.2f}")The matrix is one API call that replaces five serial route calls. At volume — an automated quoting engine processing hundreds of load enquiries per hour — that difference in call count translates directly into cost and latency.
Node example for a web quoting form
Many brokerage quoting tools are browser-adjacent. Here is the same two-call pattern in Node, matching what a lightweight quoting API endpoint might look like:
const API = 'https://csv2geo.com/api/v1';
const KEY = process.env.CSV2GEO_KEY;
async function geocodeAddress(address) {
const url = `${API}/geocode?q=${encodeURIComponent(address)}&api_key=${KEY}`;
const r = await fetch(url);
if (!r.ok) throw new Error(`Geocode failed: ${r.status}`);
const data = await r.json();
const best = data.results?.[0];
if (!best) throw new Error('No geocode result');
return { lat: best.lat, lng: best.lng, confidence: best.confidence ?? 0 };
}
async function truckRoute(originLat, originLng, destLat, destLng) {
const params = new URLSearchParams({
origin: `${originLat},${originLng}`,
destination: `${destLat},${destLng}`,
mode: 'TRUCK',
units: 'imperial',
api_key: KEY,
});
const r = await fetch(`${API}/route?${params}`);
if (!r.ok) throw new Error(`Route failed: ${r.status}`);
const data = await r.json();
return {
miles: data.distance_miles,
durationHours: data.duration_seconds / 3600,
};
}
async function instantQuote(originAddr, destAddr, ratePerMile = 3.20) {
const [origin, dest] = await Promise.all([
geocodeAddress(originAddr),
geocodeAddress(destAddr),
]);
if (origin.confidence < 0.7 || dest.confidence < 0.7) {
return { error: 'low_confidence', message: 'Please verify addresses before quoting.' };
}
const route = await truckRoute(origin.lat, origin.lng, dest.lat, dest.lng);
const total = Math.max(route.miles * ratePerMile, 250);
return {
miles: route.miles.toFixed(1),
durationHours: route.durationHours.toFixed(1),
quote: total.toFixed(2),
};
}The Promise.all on the geocode calls matters. Waiting for origin then destination serially adds unnecessary latency on every single quote request. Fire them in parallel; they are independent calls.
Caching — the difference between a cheap pipeline and an expensive one
Freight lanes repeat. The Chicago-to-Denver lane that a shipper asked about at 09:00 today is the same road network that another shipper will ask about at 14:00 tomorrow. The road distance does not change between calls. Caching route results is not an optimisation — it is the responsible default.
A simple Redis-keyed cache with a long TTL:
import hashlib, json, redis
cache = redis.Redis(host="localhost", decode_responses=True)
ROUTE_TTL = 86400 * 7 # 7 days — roads do not repave overnight
def cached_route(origin_lat, origin_lng, dest_lat, dest_lng, mode="TRUCK"):
key = hashlib.md5(
f"{origin_lat:.4f},{origin_lng:.4f}|{dest_lat:.4f},{dest_lng:.4f}|{mode}".encode()
).hexdigest()
cached = cache.get(f"route:{key}")
if cached:
return json.loads(cached)
result = get_route(origin_lat, origin_lng, dest_lat, dest_lng, mode)
cache.setex(f"route:{key}", ROUTE_TTL, json.dumps(result))
return resultThe coordinate rounding to 4 decimal places (~11 m precision) means that two geocode results for the same warehouse that land a few metres apart hit the same cache key. The caching post covers this pattern in full; the principle applies to routing calls with equal force.
For geocoding results specifically, cache even more aggressively. An address string that maps to a coordinate today maps to the same coordinate tomorrow. Geocoding the same terminal or warehouse address on every quote request when you have already geocoded it fifty times this week is pure waste.
How to build the quoting flow — step by step
Step 1: Capture and geocode addresses at input time
Do not wait until the user clicks "Get Quote" to geocode. As soon as the origin field loses focus, fire the geocode call in the background. By the time they finish typing the destination, the origin coordinates are already in your local state. Instant-feeling quote generation is mostly about hiding the geocode latency before the user wants the answer.
Surface a confidence warning inline — "We could not pin this address precisely, please verify" — rather than silently returning a quote built from a guessed coordinate. A mis-geocoded warehouse in New Jersey that gets placed in New York adds meaningful phantom miles to every lane that runs through that node.
Step 2: Call the route endpoint in truck mode and cache the result
With both coordinates confirmed, call /api/v1/route?mode=TRUCK. Cache the result keyed on the rounded coordinate pair. Read from cache first; only hit the API on a cache miss. For a quoting engine handling a few hundred lanes with heavy lane repetition, the cache-hit ratio after the first week typically reaches 70-80% without any tuning — freight networks are not random; they are hub-and-spoke patterns with a small number of high-frequency lanes.
Step 3: Apply your own rate card and margin logic
The API returns miles and hours. Everything after that is your data: base rate per mile, fuel-surcharge multiplier, minimum load charge, customer-specific discounts, spot-market premiums. The Routing API has no opinion on any of this — it gives you the physical facts of the route and leaves the commercial logic entirely in your hands. Keep that separation clean. Do not try to embed rate logic in API parameters; embed it in your application code where it is versioned, testable, and auditable.
Step 4: Show the quote with the supporting numbers
A quote that reads "$1,847.00" is less credible than one that reads "1,124 miles · 17.2 h drive time · $1,847.00 at your contracted rate of $1.64/mile." Show your working. The shipper's operations team will cross-check the mileage against whatever tool they already use. If your number agrees with theirs, you build trust. If it disagrees, you want to know why — and you want to find out before the load is booked, not after a margin dispute on the invoice.
Step 5: Build the N×M matrix for multi-lane quoting
When your system prices one shipment against multiple routing options or multiple carrier hubs simultaneously, switch from serial route calls to a matrix call. Define the origins list (may be one origin) and the destinations list (all candidate hubs or delivery points). One matrix call returns the full grid. Sort the results by total quote, surface the cheapest viable lane to the shipper, and log the full matrix to your data warehouse — the lane-cost data is valuable for rate negotiation cycles.
Step 6: Monitor the geocode confidence distribution in production
Add a metric to your observability stack that tracks the distribution of geocode confidence scores for quotes. A confidence distribution that creeps towards the low end is an early signal that your shipper input UI is accepting poorly-formatted addresses — perhaps a legacy integration is sending abbreviations, or a web form is missing ZIP code validation. Catching this at the geocode layer is far cheaper than catching it when a driver gets sent to the wrong facility. The observability post covers the instrumentation pattern.
Failure modes to design for explicitly
Geocode returns no result. An address like "Warehouse B, Junction 14" means something inside the shipper's organisation and nothing to a geocoding engine. Return a clear error to the user, not a 500. The user experience should be "we couldn't locate this address — can you add a street number and ZIP?" not a spinner that times out.
Route returns a very high distance. If the truck-mode route returns a suspiciously large number — say, an origin in Phoenix and a destination in Tucson routing through Nevada — the routing engine may have fallen back to an unusual path to avoid a weight-restricted segment. Log these outliers. A threshold check ("distance more than 3× the great-circle distance for this pair") is a cheap sanity filter worth adding.
One geocode call is slow and the other is fast. If you run geocodes in parallel and one hangs, Promise.all or ThreadPoolExecutor will wait for both. Set a tight timeout (10-15 seconds) on each geocode call individually, not a single outer timeout on the pair. That way a single slow geocode does not stall the whole form. See exponential backoff and when to stop for the retry discipline that belongs around each individual call.
Rate-card data races. Your rate card changes. The route distance does not. Cache routes aggressively; do not cache computed quotes. If you cache the dollar figure alongside the route distance and the rate card updates overnight, you will serve stale quotes until TTL expiry. Keep the cache layer at the physical facts level — miles, hours — and compute the dollar amount fresh on every quote render.
API credit cost for a quoting engine
A concrete cost picture for a team that quotes freight before building it.
A brokerage handling 300 unique quote requests per day, with a 70% cache-hit rate on route calls after the first week of operation:
- 300 geocode requests × 2 ends × 1 credit = 600 geocode credits/day
- 300 route requests × 0.30 cache-miss rate = 90 routing credits/day
- Total: roughly 690 credits/day = ~20,700 credits/month
The free tier covers 3,000 calls/day — a new team can build and test the full quoting flow, including matrix calls for lane comparison, without a card. Paid tiers start at $54/month for 100,000 calls; see csv2geo.com/pricing/api for current brackets.
A team doing 3,000 quote requests per day (a busy mid-size brokerage) with a 75% cache-hit rate on routing calls stays within the 100,000 call/month bracket at the entry paid tier. The lever that keeps costs predictable is the cache — the caching post covers the full implementation pattern.
What this replaces and what it does not
The two-call pattern above replaces the call-back cycle for distance-based pricing — the "let me check and get back to you" that loses freight bookings to faster brokers.
It does not replace:
Fuel-surcharge tables. The API returns road distance. Fuel-surcharge indices — diesel price benchmarks, weekly index updates, carrier-specific multipliers — are your data, fed from your own sources. The distance is the input; the surcharge arithmetic is your business logic.
Live carrier availability. The quote tells the shipper what a lane costs at your rate card. It does not tell you whether your preferred carrier has a truck in Chicago on Tuesday. That is a carrier-management workflow layered on top of the quoting engine, not a substitute for it.
HOS-aware drive-time planning. The drive time returned is a road-network estimate. It does not model hours-of-service rest requirements, multi-driver teams, or shipper/receiver appointment windows. Operations planning that depends on HOS compliance needs a TMS-layer calculation on top of the raw drive time. The API gives you the road physics; compliance logic is yours.
Real-time traffic on long-haul lanes. For a 1,200-mile cross-country lane, real-time traffic conditions at the moment of quoting have essentially no predictive value for a load that picks up in 48 hours. Traffic-sensitive routing matters at the city-delivery and last-mile level — that is a different product from freight brokerage quoting.
Frequently Asked Questions
Can I use the Routing API for LTL lane pricing, not just FTL?
Yes. The road distance and drive time are the same physical facts regardless of whether the load is full truckload or less-than-truckload. The rate-per-mile multiplier, minimum charges, and accessorial logic are different for LTL — but those live in your rate card, not in the API call. Apply the appropriate rate-card logic to the same distance figure.
What truck attributes does TRUCK mode respect?
The truck routing mode accounts for weight limits, overhead clearances, and route classifications that restrict commercial vehicles. You can pass truck-specific attributes — gross weight, height, hazmat class — as parameters to refine the routing for specialised loads. Refer to the API documentation for the full parameter list; the attributes available are documented at the endpoint level, not fixed to a single configuration.
How do I handle a shipper who gives me a city name rather than a full address?
Geocoding a city name returns the centroid of that city, which is not a usable dispatch coordinate — it might be in the middle of a park or a river. Surface a prompt in the quoting UI that asks for a full street address or ZIP+4. City-level quotes are meaningless for lane pricing. Build validation into the input field rather than accepting vague inputs and silently producing wrong distances.
Is the 504M+ address database relevant to freight quoting?
It is relevant at the geocoding step. CSV2GEO covers 504M+ addresses across 63 countries. For a US freight brokerage, the coverage means that obscure industrial addresses — rural distribution centres, agricultural processing facilities, remote terminals — geocode correctly rather than falling back to a city centroid or returning no result. That matters most at the tail of your lane distribution, where the unusual pick-up or delivery point is also the one where a mis-geocoded address costs you most.
Can I pass the route geometry to a shipper-facing map?
Yes. Request geometry=true on the route call and the response includes a polyline you can render on any mapping library. The geometry is the truck-mode path, so it reflects the actual road the driver is expected to take, not a straight line or a passenger-vehicle route. For a shipper-facing quote confirmation screen, showing the actual route builds confidence in the mileage figure.
What happens if the origin and destination are in different countries?
The Routing API handles cross-border routes where the road network is continuous — for example, a US-Canada cross-border lane. For routes that require sea crossings or where border routing is ambiguous, verify the output against a known reference. Log confidence scores and distance outlier checks (distance vs. great-circle distance ratio) and route outliers to a manual review queue.
Do SDKs exist, or do I have to write raw HTTP calls?
SDKs for Python and Node are available. This post shows REST directly because production freight quoting pipelines typically wrap API calls in their own thin clients — it is one function per endpoint, there is no benefit to a dependency that needs version-pinning and update monitoring. The REST surface is simple enough that a custom wrapper is less work than managing an SDK version across your service fleet.
Related Articles
- Dispatch console — 5,000 stops per day — the operational dispatch workflow that sits downstream of the quoting motion covered here
- Benchmarking geocoding APIs — honest numbers — what to measure when evaluating the geocoding accuracy that underpins your distance figures
- Caching geocoding results — 90% cost reduction — the caching pattern that keeps a high-volume quoting engine within a predictable cost bracket
- Exponential backoff — when to retry, when to stop — retry discipline for each geocode and route call in a parallel quoting pipeline
- Concurrency tuning — geocoding sweet spot — how to tune parallel geocode and route calls without tripping rate limits at volume
---
*I.A. / CSV2GEO Creator*
Use our batch geocoding tool to convert thousands of addresses to coordinates in minutes. Start with 100 free addresses.
Try Batch Geocoding Free →