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)

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:

  1. When the order is placedPUT /orders/{order_number} with the order fields (creates the order; the endpoint is an upsert).
  2. When the order shipsPUT /orders/{order_number} again, this time including the shipment under shipments[].
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 /orders is 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, and line_items[] (each with sku, name, quantity, unit_price). customer.email is 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; currency and store_id are set from your account and ignored if sent. So the "when it ships" call above re-sends the same order snapshot plus shipments[] — you can't PUT shipments[] 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 / quantity to override what's on the order line.
  • If no order line matches the key, the item is stored as-sent (no title is invented).
  • quantity is filled from the order line's ordered quantity only as a convenience. For a split order (see below) always send quantity per 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, use PATCH /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

If 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:

StatusMeaning
401Missing or invalid Bearer token — request a new one via /auth/v1/token
403Domain mismatch or revoked API key
404Tracking number not found for your store
409Duplicate tracking_number — already registered (see existing); use PATCH /shipments/{tracking_number}
422Validation error (check detail + the errors array), or Tracking / OMS push not enabled for the store
429Rate limit — back off and retry (20 tokens / 15 min per API key)

Next steps


Did this page help you?