Tracking Integration
Push your shipment tracking numbers from your OMS / fulfilment system into Protect+ so the customer-facing tracking page, status timeline, and outbound webhook deliveries all stay in sync with carrier reality.
This guide walks you through the three Shipments endpoints — push, update, and read — plus the operational patterns (idempotency, error handling, status mapping) you need to wire it up cleanly.
Auth required. Every Shipments endpoint requires a JWT in
Authorization: Bearer <jwt>. Get one by exchanging your API key for a token — see the API keys & auth guide.
Two integration shapes
You can push to Protect+ in two flavours, depending on what your OMS knows when it produces the shipment record:
| Mode | When to use | Required fields |
|---|---|---|
| Order-linked | Your OMS knows the platform order number when shipping. | tracking_number, carrier, order_number |
| Tracking-only | Your fulfilment centre prints labels before the order is associated. | tracking_number, carrier |
Tracking-only shipments are tracked through the carrier lifecycle the same way; they're just not joined to a Protect+ order. You can attach the order later via PATCH (see below).
Push a shipment — POST /shipments
POST /shipmentsUse this on label print or first carrier scan, whichever your OMS exposes first. Pushing the same tracking_number twice for the same store is rejected with 409 Conflict — see the Update a shipment section for how to edit an existing shipment, or Handling duplicates below for the recovery pattern.
Minimal request (tracking-only)
curl -X POST https://api.protectplus.io/shipments \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{
"tracking_number": "1Z999AA10123456784",
"carrier": "ups"
}'Full request (order-linked + customer tracking page enrichment)
curl -X POST https://api.protectplus.io/shipments \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{
"tracking_number": "1Z999AA10123456784",
"carrier": "ups",
"order_number": "ORD-12345",
"customer_email": "[email protected]",
"shipped_at": "2026-05-12T14:00:00Z",
"eta": "2026-05-15T18:00:00Z",
"items": [
{
"sku": "SKU-1001",
"title": "Protect+ Premium Mug",
"quantity": 1,
"image_url": "https://cdn.example.com/products/mug-12oz-black.png",
"line_item_id": "47123456789"
}
],
"servicelevel": {
"name": "Ground",
"terms": "1-5 business days",
"token": "ups_ground"
},
"ship_to": {
"name": "Jane Doe",
"street1": "123 Main St",
"city": "Austin",
"state": "TX",
"zip": "78701",
"country": "US"
}
}'Field rules
| Field | Required | Notes |
|---|---|---|
tracking_number | ✅ | ≤ 64 chars. Stored as-is, used as the join key for updates. |
carrier | ✅ | Shippo carrier token: lowercase letters, digits, underscores, 2–40 chars (ups, fedex, usps, fedex_smartpost, dhl_express, …). See Shippo's reference for the full list. |
order_number | optional | Platform order number. Omit for tracking-only mode. |
customer_email | optional | Used for delivery notifications + the customer-facing tracking page. PII — handle accordingly. |
shipped_at, eta | optional | ISO 8601. The carrier may overwrite these as it reports actual scans. |
items[] | optional | Per-item: sku, title, quantity, image_url, line_item_id. title / quantity / image_url render on the customer tracking page. sku and line_item_id are stored for reconciliation but never shown to the customer. |
servicelevel | optional | Mirrors Shippo's servicelevel shape (name, terms, token, extended_token). |
ship_to | optional | Delivery address. Surfaced on responses as address_to. |
Response codes
| Status | Meaning | Action |
|---|---|---|
201 | New shipment created and queued for carrier registration. | Continue. |
202 | Persisted, carrier registration deferred (transient Shippo error). A background job will retry. | Continue, no action needed. |
400 | Validation error. | Fix the body and retry. |
401 | JWT missing / expired / revoked. | Refresh the JWT (see auth guide). |
409 | A shipment with this tracking_number already exists for this store (type: errors/tracking-number-already-exists). The existing shipment metadata is included in the existing extension. | Switch to PATCH /shipments/{tracking_number} to update editable fields. See Handling duplicates below. |
422 | Store not configured. type distinguishes: errors/tracking-not-enabled (toggle in dashboard) or errors/oms-push-disabled (contact account manager). | See troubleshooting below. |
429 | Rate limit exceeded (600 req/min/store). | Honour Retry-After. |
502 / 504 | Upstream (orders service) unreachable / timed out. | Retry with backoff — if the original request actually persisted, the retry returns 409 with the existing shipment in existing; pivot to PATCH from there. |
Handling duplicates (409 tracking-number-already-exists)
409 tracking-number-already-exists)Re-POSTing the same (store, tracking_number) returns 409 with a structured problem-details body. The existing shipment's identifiers are included so your OMS can recover without an additional GET:
{
"type": "https://api.protectplus.io/errors/tracking-number-already-exists",
"title": "Conflict",
"status": 409,
"detail": "A shipment with this tracking number already exists for this store. Use PATCH /shipments/{tracking_number} to update editable fields.",
"instance": "/shipments",
"requestId": "…",
"traceId": "…",
"existing": {
"shipment_id": "664f0abc123def4567890abc",
"tracking_number": "1Z999AA10123456784",
"carrier": "ups"
}
}Switch on type (not on detail) to detect this case programmatically.
Recovery pattern:
- Detect
status === 409andtypeending intracking-number-already-exists. - If you needed to update fields (e.g. add
order_number, refresheta), callPATCH /shipments/{tracking_number}with just the changed fields. - If you only wanted to confirm existence, use the
existingpayload directly — no extraGETneeded.
Update a shipment — PATCH /shipments/{trackingNumber}
PATCH /shipments/{trackingNumber}Edit the optional metadata on a shipment that's already in Protect+. Use this when ETA shifts, the merchant attaches an order number to a previously tracking-only shipment, or you want to add line-item enrichment after the fact.
Editable vs. immutable
Editable — send any of these, in any combination:
order_number, customer_email, tracking_url, shipped_at, eta,
items, servicelevel, ship_to, metadata, totals
Immutable — tracking_number and carrier cannot be changed. They form the shipment's identity. To "change" a carrier, register a new shipment via POST /shipments.
Null-clear semantics (RFC 7396)
omit field → leave untouched
field: null → clear it
field: <value> → set / replace
Example — remove a previously-set ETA without touching anything else:
curl -X PATCH https://api.protectplus.io/shipments/1Z999AA10123456784 \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{ "eta": null }'"No updatable fields" gotcha
The body must contain at least one editable field. An empty body or a body that only contains tracking_number / carrier returns:
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{
"type": "https://api.protectplus.io/errors/validation",
"title": "Bad Request",
"status": 400,
"detail": "No updatable fields supplied"
}Response codes
200 (success), 400 (validation / immutable field sent / no updatable fields), 401, 404 (type: errors/shipment-not-found — no such tracking number for this store; POST /shipments first to register it), 422 (store not enabled), 429, 502, 504. Note: 409 is not emitted by PATCH — duplicate-detection only happens on POST.
Read a shipment — GET /shipments/{trackingNumber}
GET /shipments/{trackingNumber}Retrieves the full timeline + current status for a single tracking number in your store. Useful for support tooling, reconciliation, and customer inquiry handlers.
curl https://api.protectplus.io/shipments/1Z999AA10123456784 \
-H "Authorization: Bearer $JWT"The response includes the canonical event timeline (each step the carrier has reported), the current canonical_status, and the same enrichment fields you pushed (items, servicelevel, address_to, totals).
List endpoints not yet public.
GET /shipmentsandGET /shipments/{shipment_id}are not exposed via the public API. Use tracking-number lookup or rely on outbound webhooks for event-driven integrations.
Canonical event reference
These are the event_type values you'll receive on outbound webhooks (see the Outbound webhooks guide) as a shipment moves through its carrier lifecycle. Distinct from tracking_status on GET /shipments/{trackingNumber}, which carries the underlying carrier state (PRE_TRANSIT, TRANSIT, OUT_FOR_DELIVERY, DELIVERED, RETURNED, FAILURE, UNKNOWN).
Canonical event (event_type) | Underlying carrier status | Meaning |
|---|---|---|
shipment_created | PRE_TRANSIT | Label printed but not yet scanned. |
shipped | TRANSIT | First in-transit scan. |
out_for_delivery | TRANSIT (sub: out_for_delivery) | On the truck for final delivery. |
delivered | DELIVERED | Confirmed delivery scan. |
exception | RETURNED / FAILURE | Delivery failure / address issue / returned to sender. |
Error type reference
All errors are RFC 7807 problem-details. Switch on the type field for stable programmatic handling — detail is human-readable and may change.
type slug | HTTP | Where it can fire | Notes |
|---|---|---|---|
errors/validation | 400 | All endpoints | Body / params failed validation. extensions.errors[] lists field-level failures. |
errors/unauthorized | 401 | All endpoints | JWT missing / expired / revoked. Mint a new one. |
errors/tracking-number-already-exists | 409 | POST /shipments | Duplicate (store, tracking_number). Use extensions.existing to recover; switch to PATCH for updates. |
errors/shipment-not-found | 404 | PATCH /shipments/{trackingNumber}, GET /shipments/{trackingNumber} | No matching shipment in this store. POST /shipments first to register. |
errors/tracking-not-enabled | 422 | POST / PATCH /shipments | Toggle tracking on in the merchant dashboard. |
errors/oms-push-disabled | 422 | POST / PATCH /shipments | Plan does not allow OMS push. Contact account manager. |
errors/gateway-timeout | 504 | All endpoints | Orders service did not respond in time. Retry per the rules in Troubleshooting → 502 / 504. |
errors/bad-gateway | 502 | All endpoints | Orders service unreachable / returned an unexpected status. Same retry rules as above. |
Troubleshooting
422 errors/tracking-not-enabled
422 errors/tracking-not-enabledTracking is disabled for the store the JWT is scoped to. Toggle Tracking → Enable in the merchant dashboard, then retry.
422 errors/oms-push-disabled
422 errors/oms-push-disabledThe merchant account isn't on a plan that allows OMS-push ingestion. Contact your Protect+ account manager.
401 token-expired
401 token-expiredJWTs are valid for 60 minutes. The recommended pattern is to refresh proactively at ~55 minutes OR catch the 401, refresh once, and retry the original request. See the API keys & auth guide.
409 tracking-number-already-exists on POST
409 tracking-number-already-exists on POSTA shipment with this (store, tracking_number) already exists. This is the standard response for any retry of POST /shipments after the first successful create — not just a concurrent-write race. The response includes the existing shipment's shipment_id, tracking_number, and carrier in the existing extension.
If you need to update any editable field, switch to PATCH /shipments/{trackingNumber}. If you just want to read the current state, use the existing payload from the 409 directly, or GET /shipments/{trackingNumber} for the full timeline. See Handling duplicates in the POST section above for the full pattern.
Rate limit (429)
429)The Shipments endpoints are rate-limited at 600 req/min per store (machine-caller limiter, separate from the global per-IP limit). Honour the Retry-After header and apply backoff. If you regularly hit this in steady state, batch your pushes by sending fewer, larger requests is not an option — the endpoint is single-shipment by design — so reach out to your account manager about the limiter ceiling.
502 / 504 from upstream
502 / 504 from upstreamPATCH is idempotent by definition — retry blindly.
POST is not idempotent. Retry once, and if the original request actually persisted, the retry will surface as 409 tracking-number-already-exists with the existing shipment in the existing extension. Treat that 409 as success, pivot to PATCH if you need to apply updates, and do not retry further.
Best practices
- Push on label print, not on scan. Earlier push = earlier customer visibility on the tracking page.
- Always include
customer_email. It's required for delivery notifications and the customer-facing page header. - Include
items[]whenever you can. The customer tracking page is a high-touch surface; an item list with images dramatically improves the merchant's brand experience. - Use
PATCHinstead of re-POST'ing for updates. Re-POSTing returns409 tracking-number-already-exists;PATCH /shipments/{tracking_number}is the only way to edit an existing shipment. - Verify outbound webhooks. Once the carrier starts reporting events, they flow to your configured outbound endpoint. See the Outbound webhooks guide for verification.
Related
- API keys & auth — how to mint a JWT.
- Outbound webhooks — how to receive events back from Protect+.
- API Reference —
POST /shipments·PATCH /shipments/{trackingNumber}·GET /shipments/{trackingNumber}