Catching Delivery Exceptions Before Your Customers Do
Turn raw carrier tracking events into routed delivery exceptions: late and stalled parcels, address problems, losses and disputes. A scheduled pipeline with curl, TypeScript and Python.
Most "where is my order?" tickets are predictable. The tracking page told the story days earlier: a weather delay in Denver, then nothing. No scan for two days, and the promised date three days gone. The customer noticed before you did because nobody reads tracking events for parcels that look fine.
This guide builds a small job that reads tracking for every in-flight shipment, spots the ones that have gone wrong, and sends each to the right workflow: contact the carrier, contact the customer, or open an investigation. The interpretation step uses the Kovca Shipment Intelligence API. Kovca never fetches tracking or contacts carriers. You supply the events, and it tells you what they mean.
Why tracking is hard to read in code
Every carrier says it differently. "Delivery exception: weather delay at destination facility", "EX - WTHR", and "Shipment on hold" can all mean the same thing. A keyword list misses some of them and catches the wrong ones.
Silence is a signal. The most important event is often the one that didn't happen. A parcel with no scan for three days can't be found by text matching, because there's no text to match.
Dates need arithmetic, not judgement. "Late" means the current time is past the promised date. That's subtraction, and it should never be estimated.
Kovca splits the work the same way. Days late and days since the last scan are computed in code from your timestamps. Carrier wording and customer messages are interpreted to find the type of problem.
The pipeline
- 1Schedulerevery 4–6 hours, in-flight shipments only
- 2Tracking storeevents from carrier webhooks or polling
- 3Your policyno_scan_days, escalate_days_late per service level
- 4Kovca Shipment Intelligenceexception_type, priority, recommended_actionInvestigationsdamaged, lost, disputes: requires_human_review
- 5Workflow routercarrier ticket, customer email, or keep monitoring
- 6Order systemrequest_id, last result, next check time
1Pick the shipments worth checking
Don't re-check everything every hour. Select shipments that are in transit, have had a new event since the last check, or have passed a time threshold (for example, no scan in 48 hours). Delivered shipments with no customer complaint can drop out of the loop.
2Send the evidence
Send up to 50 events in any order. Kovca sorts them and tells you if they arrived out of order. Include promised_delivery_date whenever you have one. Without it, a delay is still reported, but its lateness can't be measured, and missing_information will say so. Set as_of when you replay old shipments so results are reproducible.
curl
curl https://kovca.io/api/v1/shipments/triage \
-H "Authorization: Bearer $KOVCA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"shipment_id": "SHP-48213",
"carrier": "Example Parcel",
"events": [
{ "timestamp": "2026-03-02T09:10:00Z", "status": "PU", "description": "Picked up", "location": "Columbus, OH" },
{ "timestamp": "2026-03-03T22:41:00Z", "status": "IT", "description": "Departed sort facility", "location": "Indianapolis, IN" },
{ "timestamp": "2026-03-05T14:05:00Z", "status": "EX", "description": "Delivery exception: weather delay at destination facility", "location": "Denver, CO" }
],
"promised_delivery_date": "2026-03-05",
"as_of": "2026-03-08T12:00:00Z",
"service_policy": { "no_scan_days": 2, "escalate_days_late": 3 }
}'{
"request_id": "req_Tz7nW2qLr5YbK9cM",
"exception_type": "delayed",
"priority": "high",
"recommended_action": "contact_carrier",
"requires_human_review": false,
"signals": ["past_promised_date", "no_recent_scan"],
"missing_information": [],
"days_past_promised": 3,
"days_since_last_event": 2
}The parcel is three days late and has had no scan for two days. That reaches your escalate_days_late threshold, so priority is high. The next step is a call to the carrier, not an apology email.
TypeScript (Node 18+)
type TrackingEvent = { timestamp: string; description: string; status?: string; location?: string };
type ShipmentResult = {
request_id: string;
exception_type: "delayed" | "delivery_failed" | "address_issue" | "damaged" | "lost_or_missing" | "delivery_dispute" | "customs_hold" | "no_exception" | "other";
priority: "low" | "medium" | "high";
recommended_action: "monitor" | "contact_carrier" | "contact_customer" | "request_information" | "investigate" | "manual_review";
requires_human_review: boolean;
signals: string[];
missing_information: ("tracking_events" | "promised_delivery_date")[];
days_past_promised?: number;
days_since_last_event?: number;
};
export async function checkShipment(s: {
id: string;
carrier: string;
events: TrackingEvent[];
promisedBy?: string;
customerMessage?: string;
}): Promise<ShipmentResult> {
const res = await fetch("https://kovca.io/api/v1/shipments/triage", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.KOVCA_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
shipment_id: s.id,
carrier: s.carrier,
events: s.events.slice(-50), // the most recent 50
promised_delivery_date: s.promisedBy,
customer_message: s.customerMessage?.slice(0, 2000),
service_policy: { no_scan_days: 2, escalate_days_late: 3 },
}),
});
if (!res.ok) throw new Error(`shipment check failed: ${res.status}`); // retry on the next scheduled run
return res.json();
}Python
import os, requests
def check_shipment(shipment_id, carrier, events, promised_by=None, customer_message=None):
body = {
"shipment_id": shipment_id,
"carrier": carrier,
"events": events[-50:],
"service_policy": {"no_scan_days": 2, "escalate_days_late": 3},
}
if promised_by:
body["promised_delivery_date"] = promised_by
if customer_message:
body["customer_message"] = customer_message[:2000]
r = requests.post(
"https://kovca.io/api/v1/shipments/triage",
headers={"Authorization": f"Bearer {os.environ['KOVCA_API_KEY']}"},
json=body, timeout=30,
)
r.raise_for_status() # a scheduled job can simply try again next run
return r.json()A scheduled job has a useful property: retries are free. If a request fails, the shipment is picked up on the next run. You don't need a retry loop, but do honor Retry-After on 429 RATE_LIMITED so a big batch doesn't keep hitting the limit.
3Route each exception to one workflow
| exception_type | recommended_action | Your workflow |
|---|---|---|
delayed | monitor, or contact_carrier once late or silent | Open a carrier trace. Email the customer a new estimate before they ask. |
delivery_failed, address_issue, customs_hold | contact_customer | Ask the customer to fix the address, arrange redelivery, or provide customs documents. |
damaged, lost_or_missing, delivery_dispute | investigate | Assign to a person. Hold refunds and reships until they decide. |
no_exception | monitor | Check again on the next run. |
other or unclear | manual_review | Assign to a person with the raw events. |
requires_human_review is true for damage, loss, disputes, other, contradictory tracking, and unclear types. Treat it as a hard stop for automation: no refunds, reships, or claims without a person.
4Fold in the customer's side
When a "where is my order?" ticket arrives, run the same check with the customer's words in customer_message. That's how a "delivered" scan becomes a delivery_dispute: tracking says it was left at the door, and the customer says it wasn't. Tracking data alone can never show that.
Rolling it out
- Read-only first. Run the job for a week and post a daily digest of
highpriority exceptions to a team channel. Compare it with the tickets that arrived. - Proactive emails for delays. Automate the "your order is running late" email for
delayedwithcontact_carrier. Most customers would rather hear it from you first. - Leave the rest with people. Investigations stay manual. The win is that they start days earlier and with the evidence already attached.
Volume and cost
Cost scales with in-flight shipments × checks per day. Checking only shipments with new events or a crossed threshold keeps it small. Free covers 1,000 successful requests a month at 10 per minute. Pay As You Go is $0.005 per successful request at 60 per minute, with a spending cap (pricing).
Try it on last week's late parcels
Export the tracking history for five shipments that turned into tickets and run them through with as_of set to the day before the customer wrote in. Sign up free, create a key, and try it in the playground. The routing table and every signal are in the Shipment Intelligence docs.