Tracking Integration
Order Tracking — Integration guide
End-to-end walkthrough for the Order Tracking surface of the Get Plus Public API: generate an API key, request a JWT, register a shipment, look it up, and (optionally) wire up outbound webhooks.
Targeting a different feature area (Claims, Returns, etc.)? Start at the API Reference — each surface has its own guide.
Base URL: https://api.protectplus.io (production) or https://services-public-apis-dev-v1-112152972940.us-west1.run.app (dev)
1. Generate an API key
In your merchant dashboard go to Tracking → Settings → Integration. Click Generate API key — the key is shown once; copy it immediately.
Your key looks like pplus_<64-hex-chars>.
2. Request a JWT
curl -s -X POST $BASE_URL/auth/v1/token \
-H "X-API-Key: $API_KEY" \
-H "X-Shop-Domain: $SHOP_DOMAIN" | jq .Response:
{
"success": true,
"request_id": "...",
"trace_id": "...",
"data": {
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 3600,
"expires_at": "2026-05-14T10:00:00.000Z",
"issued_at": "2026-05-14T09:00:00.000Z",
"jti": "a1b2c3d4-..."
}
}The token is valid for 60 minutes. Cache it and refresh before expiry.
3. Register a shipment
Once an order exists in Get Plus, register a shipment so Get Plus tracks it with the carrier and sends the customer shipment/tracking notifications. There are two ways to do this — the recommended path for OMS integrations is to include the shipment on the order upsert; POST /shipments is available as an alternative.
Recommended — include it on the order (PUT /orders)
PUT /orders)If you push orders to Get Plus with PUT /orders/{order_number}, you can register a shipment for that order in the same request by including a shipments[] array — no separate POST /shipments call needed:
- When the order is placed —
PUT /orders/{order_number}with the order fields (creates the order; the endpoint is an upsert). - When the order ships —
PUT /orders/{order_number}again, this time including the shipment undershipments[].
curl -s -X PUT $BASE_URL/orders/ORD-1234 \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"platform": "custom",
"ordered_at": "2026-07-01T12:00:00Z",
"currency": "USD",
"customer": { "email": "[email protected]" },
"amounts": { "subtotal": 49.99 },
"line_items": [ { "sku": "SKU-1", "name": "Garnet Ring", "quantity": 1, "unit_price": 49.99 } ],
"shipments": [
{
"tracking_number": "1Z999AA10123456784",
"carrier": "ups",
"service_level": "ground",
"items": [ { "sku": "SKU-1", "quantity": 1 } ]
}
]
}' | jq .
PUT /ordersis a full replace (upsert) — send the complete order body on every call, not just the fields that changed. Required each time:platform,ordered_at,amounts.subtotal, andline_items[](each withsku,name,quantity,unit_price).customer.emailis optional — send a valid email, an empty string,null, or omit it (phone-primary orders are fully supported; email notifications simply skip when it's absent). Omitted line items are dropped;currencyandstore_idare set from your account and ignored if sent. So the "when it ships" call above re-sends the same order snapshot plusshipments[]— you can't PUTshipments[]alone. See Create or update an order for the full field reference.
Each shipment you include is registered exactly like a POST /shipments call: Get Plus registers the tracking number with the carrier, streams the lifecycle events, and sends the customer the same shipment/tracking notifications. Because the shipment is attached to the order in one call, it lands under the same returnable order (identical line_items / line_ids).
Item display enrichment
A shipment item only needs a reconciliation key — either sku, or line_item_id matching an order line's line_id. Get Plus fills in the missing display fields (title, image_url, and quantity) from the matching order line so the customer tracking page shows the product name and image without you having to repeat them:
"shipments": [
{
"tracking_number": "1Z999AA10123456784",
"carrier": "ups",
"items": [ { "sku": "SKU-1", "quantity": 1 } ] // title + image_url filled from the order line
}
]- Values you send explicitly are never overwritten — pass your own
title/image_url/quantityto override what's on the order line. - If no order line matches the key, the item is stored as-sent (no title is invented).
quantityis filled from the order line's ordered quantity only as a convenience. For a split order (see below) always sendquantityper package — otherwise each package's tracking page would show the full ordered quantity, not the amount in that box.
Multiple shipments (split order)
When an order ships in more than one package, include several entries in shipments[] in the same PUT — even across different carriers. Each is registered and tracked independently under the same order, and each tracking number's customer page shows only that package's items:
"shipments": [
{ "tracking_number": "1Z...A", "carrier": "ups", "items": [ { "sku": "SKU-1", "quantity": 1 } ] },
{ "tracking_number": "9400...B", "carrier": "usps", "items": [ { "line_item_id": "L2", "quantity": 1 } ] }
]Notes:
- Idempotent per tracking number — re-
PUTting the same shipment is a no-op (no duplicate, no repeat notification). To edit an already-registered shipment's descriptive fields, usePATCH /shipments/{tracking_number}. - Update the order without shipments — omit
shipments[]and the order upsert leaves existing shipments untouched. - Best-effort — if the order is accepted but a shipment can't be registered with the carrier at that moment, the order still succeeds and the shipment is saved pending carrier registration.
- Requires Tracking enabled on your store (the same entitlement as
POST /shipments). If Tracking is not enabled, the order upsert still succeeds and the shipment's items are saved on the order, but the tracking number is not registered with the carrier and no tracking notifications are sent.
Alternative — POST /shipments for an existing order
POST /shipments for an existing orderIf your order sync and shipment events come from different systems, you can register tracking as a standalone call against an order that already exists:
curl -s -X POST $BASE_URL/shipments \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"tracking_number": "1Z999AA10123456784",
"carrier": "ups",
"order_number": "ORD-1234",
"customer_email": "[email protected]"
}' | jq .Response (201 Created):
{
"success": true,
"request_id": "...",
"trace_id": "...",
"data": {
"shipment_id": "6a04...",
"tracking_number": "1Z999AA10123456784",
"carrier": "ups",
"created": true,
"already_registered": false,
"shippo_track_object_id": "..."
}
}Re-POSTing the same tracking_number returns 409 Conflict (type: .../errors/tracking-number-already-exists), with the already-registered shipment echoed under existing — it never creates a duplicate or re-notifies. Use PATCH /shipments/{tracking_number} to change editable fields. POST /shipments does not create an order — the order must already exist (create it first with PUT /orders/{order_number}).
4. Look up a shipment
curl -s $BASE_URL/shipments/1Z999AA10123456784 \
-H "Authorization: Bearer $ACCESS_TOKEN" | jq .Response:
{
"success": true,
"data": {
"branding": { ... },
"served_at": "2026-05-14T09:01:00.000Z",
"shipment": {
"tracking_number": "1Z999AA10123456784",
"carrier": "ups",
"order_number": "ORD-1234",
"tracking_status": "TRANSIT",
"tracking_status_substatus": "out_for_delivery",
"tracking_status_label": "Out for delivery",
"timeline_step": "out_for_delivery",
"eta": "2026-05-15T18:00:00Z",
"original_eta": "2026-05-15T18:00:00Z",
"service_level": { "name": "UPS Ground", "terms": "1-5 days" },
"address_from": { "city": "Fort Worth", "state": "TX", "zip": "76102", "country": "US" },
"address_to": { "city": "Austin", "state": "TX", "zip": "78701", "country": "US" },
"shipped_at": "2026-05-12T08:00:00.000Z",
"delivered_at": null,
"last_updated": "2026-05-14T08:55:00.000Z",
"is_test": false,
"messages": ["response.status.success"],
"tracking_history": [
{
"status": "TRANSIT",
"status_details": "Out for delivery",
"status_date": "2026-05-14T08:55:00.000Z",
"location": { "city": "Austin", "state": "TX", "zip": null, "country": null },
"substatus": { "code": "out_for_delivery", "text": "Out for delivery" }
}
],
"items": [
{ "title": "Garnet Ring", "quantity": 1, "image_url": "https://cdn.example.com/skus/SKU-1.jpg" }
]
}
}
}Address fields (address_from / address_to) are sanitized to city/state/zip/country. messages carries generic carrier notices (no PII) and is [] when none. Out-for-delivery is a TRANSIT substatus — read tracking_status_label / tracking_status_substatus, not a distinct tracking_status.
5. (Optional) Receive webhooks
Configure an outbound webhook URL in Tracking → Settings → Integration → Webhook card. Get Plus will POST tracking events (shipment_created, shipped, out_for_delivery, delivered, exception) to that URL, signed with an HMAC secret.
See Outbound Webhooks for payload shape and verification.
Error handling
All errors use RFC 7807 Problem Details:
{
"type": "https://api.protectplus.io/errors/validation",
"title": "Unprocessable Entity",
"status": 422,
"detail": "Request body failed validation",
"instance": "/shipments",
"errors": [
{ "param": "tracking_number", "msg": "tracking_number is required" }
],
"request_id": "...",
"trace_id": "..."
}Common status codes:
| Status | Meaning |
|---|---|
| 401 | Missing or invalid Bearer token — request a new one via /auth/v1/token |
| 403 | Domain mismatch or revoked API key |
| 404 | Tracking number not found for your store |
| 409 | Duplicate tracking_number — already registered (see existing); use PATCH /shipments/{tracking_number} |
| 422 | Validation error (check detail + the errors array), or Tracking / OMS push not enabled for the store |
| 429 | Rate limit — back off and retry (20 tokens / 15 min per API key) |
Next steps
- API Keys and Auth — token lifecycle, rotation, revocation
- Outbound Webhooks — receive tracking events at your endpoint
- API Reference — full OpenAPI spec
Updated about 1 month ago