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:

ModeWhen to useRequired fields
Order-linkedYour OMS knows the platform order number when shipping.tracking_number, carrier, order_number
Tracking-onlyYour 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

Use 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

FieldRequiredNotes
tracking_number≤ 64 chars. Stored as-is, used as the join key for updates.
carrierShippo 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_numberoptionalPlatform order number. Omit for tracking-only mode.
customer_emailoptionalUsed for delivery notifications + the customer-facing tracking page. PII — handle accordingly.
shipped_at, etaoptionalISO 8601. The carrier may overwrite these as it reports actual scans.
items[]optionalPer-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.
serviceleveloptionalMirrors Shippo's servicelevel shape (name, terms, token, extended_token).
ship_tooptionalDelivery address. Surfaced on responses as address_to.

Response codes

StatusMeaningAction
201New shipment created and queued for carrier registration.Continue.
202Persisted, carrier registration deferred (transient Shippo error). A background job will retry.Continue, no action needed.
400Validation error.Fix the body and retry.
401JWT missing / expired / revoked.Refresh the JWT (see auth guide).
409A 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.
422Store not configured. type distinguishes: errors/tracking-not-enabled (toggle in dashboard) or errors/oms-push-disabled (contact account manager).See troubleshooting below.
429Rate limit exceeded (600 req/min/store).Honour Retry-After.
502 / 504Upstream (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)

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:

  1. Detect status === 409 and type ending in tracking-number-already-exists.
  2. If you needed to update fields (e.g. add order_number, refresh eta), call PATCH /shipments/{tracking_number} with just the changed fields.
  3. If you only wanted to confirm existence, use the existing payload directly — no extra GET needed.

Update a shipment — 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

Immutabletracking_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}

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 /shipments and GET /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 statusMeaning
shipment_createdPRE_TRANSITLabel printed but not yet scanned.
shippedTRANSITFirst in-transit scan.
out_for_deliveryTRANSIT (sub: out_for_delivery)On the truck for final delivery.
deliveredDELIVEREDConfirmed delivery scan.
exceptionRETURNED / FAILUREDelivery 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 slugHTTPWhere it can fireNotes
errors/validation400All endpointsBody / params failed validation. extensions.errors[] lists field-level failures.
errors/unauthorized401All endpointsJWT missing / expired / revoked. Mint a new one.
errors/tracking-number-already-exists409POST /shipmentsDuplicate (store, tracking_number). Use extensions.existing to recover; switch to PATCH for updates.
errors/shipment-not-found404PATCH /shipments/{trackingNumber}, GET /shipments/{trackingNumber}No matching shipment in this store. POST /shipments first to register.
errors/tracking-not-enabled422POST / PATCH /shipmentsToggle tracking on in the merchant dashboard.
errors/oms-push-disabled422POST / PATCH /shipmentsPlan does not allow OMS push. Contact account manager.
errors/gateway-timeout504All endpointsOrders service did not respond in time. Retry per the rules in Troubleshooting → 502 / 504.
errors/bad-gateway502All endpointsOrders service unreachable / returned an unexpected status. Same retry rules as above.

Troubleshooting

422 errors/tracking-not-enabled

Tracking is disabled for the store the JWT is scoped to. Toggle Tracking → Enable in the merchant dashboard, then retry.

422 errors/oms-push-disabled

The merchant account isn't on a plan that allows OMS-push ingestion. Contact your Protect+ account manager.

401 token-expired

JWTs 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

A 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)

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

PATCH 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

  1. Push on label print, not on scan. Earlier push = earlier customer visibility on the tracking page.
  2. Always include customer_email. It's required for delivery notifications and the customer-facing page header.
  3. 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.
  4. Use PATCH instead of re-POST'ing for updates. Re-POSTing returns 409 tracking-number-already-exists; PATCH /shipments/{tracking_number} is the only way to edit an existing shipment.
  5. Verify outbound webhooks. Once the carrier starts reporting events, they flow to your configured outbound endpoint. See the Outbound webhooks guide for verification.

Related