Returns

Targeting a different feature area (Tracking, Claims, etc.)? Start at the API Reference — each surface has its own guide.

Base URLs

EnvironmentURL
Productionhttps://api.protectplus.io
Dev / sandboxhttps://services-public-apis-dev-v1-112152972940.us-west1.run.app

The 60-second picture

A return moves between three parties. Here's who does what:

StepWho handles it
Customer picks items and submits the returnProtect+ return portal
Checking the store's return rules (window, excluded items, questions)Protect+ return portal
Approving or rejecting the returnYour OMS (or Protect+ automatically when auto-approve is on)
Providing the shipping labelYour OMS
Paying the refundYour OMS
Keeping the customer and merchant dashboards up to dateProtect+ (automatic)
Making sure the same item can't be returned twiceProtect+ (automatic)

Protect+ never buys a label, never issues a refund, and never overrides your decision. Your job as the integrator is two-way data flow:

You → Protect+ (keep data in sync)
  PUT  /orders/{n}                    push orders as they're created/changed
  GET  /orders/{n}/returnable         see which lines can still be returned
  POST /returns                       file a return on the customer's behalf (optional)
  GET  /returns                       poll portal-created returns + statuses

You → Protect+ (report outcomes — "callbacks")
  POST /returns/{id}/decision         you approved or rejected it  ← skip when auto-approve is ON
  POST /returns/{id}/shipping-label   you issued the label
  POST /returns/{id}/refund           you paid the refund

Auto-approve mode (optional)

Some merchants don't want to review every portal return in their OMS — they enable Auto-approve return requests in the Protect+ merchant dashboard (Settings → OMS Return Settings). That setting is not exposed on the Public API; you cannot read or change it through these endpoints. Confirm with the merchant which mode they use before you build your polling logic.

Manual review (default)Auto-approve ON
Status right after portal submitEVALUATIONAPPROVED
Your OMS must send decision?Yes — approve or rejectNo — skip straight to label/refund
Label + refund callbacksRequiredStill required
POST /returns (API intake)Unaffected — still creates a return request for reviewUnaffected

When auto-approve is on, a portal return lands in APPROVED immediately. Poll GET /returns for display_status: "APPROVED" (not "EVALUATION") and send the shipping-label callback when your OMS is ready. You may still send decision: APPROVED if you want to attach an externalReference — it's idempotent (200). Sending decision: REJECTED after an auto-approval returns 409.

The one concept that prevents 90% of integration bugs

There are two different objects with two different ids. For Custom/OMS orders, POST /returns returns both — pick the right one for callbacks:

ObjectWhere you get its idCallbacks accept it?
Return request (intake record)POST /returnsdata.return_request.id❌ No — 404
Return (lifecycle record)POST /returnsdata.return_request.return_id, or GET /returns✅ Yes

The three callbacks only target a Return id. Use return_id from the POST /returns response (or an id from GET /returns). Sending callbacks the top-level id (the return-request id) is the most common integration mistake — it returns 404.


Quick start checklist

Every integration starts the same way:

  1. Authenticate — exchange your API key for a JWT (§1)
  2. Push your ordersPUT /orders/{n} whenever an order is created or changed (§2)
  3. Confirm auto-approve mode — ask the merchant (see Auto-approve mode above). Your polling filter and whether you send a decision callback depend on this.

Then pick the path that matches the merchant's setting:

Manual review (default — auto-approve OFF)

  1. Poll GET /returns for display_status: "EVALUATION"
  2. Send POST /returns/{id}/decisionAPPROVED or REJECTED
  3. Send POST /returns/{id}/shipping-label once your OMS issues it
  4. Send POST /returns/{id}/refund once the money moved

Auto-approve ON

  1. Poll GET /returns for display_status: "APPROVED" (returns skip EVALUATION)
  2. Send decision — not needed; optional if you want to attach an externalReference
  3. Send POST /returns/{id}/shipping-label once your OMS issues it
  4. Send POST /returns/{id}/refund once the money moved

§3 (returnable lookup) and the POST /returns intake are only needed if your OMS also files returns itself. POST /returns is unaffected by auto-approve — it always creates a return request for merchant review.


1. Authenticate

Exchange your API key for a JWT (valid 60 minutes — cache it and refresh before expiry):

curl -s -X POST $BASE_URL/auth/v1/token \
  -H "X-API-Key: $API_KEY" \
  -H "X-Shop-Domain: $SHOP_DOMAIN" | jq .

Use the token as Authorization: Bearer <jwt> on every call below. See API Keys and Auth for the full lifecycle.


2. Keep orders in sync

Returns are always filed against an order Protect+ knows about, so push orders as they're created or changed:

curl -s -X PUT $BASE_URL/orders/LC72540387 \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "platform": "custom",
    "ordered_at": "2026-06-01T18:42:11.000Z",
    "currency": "USD",
    "customer": { "email": "[email protected]" },
    "amounts": { "subtotal": 89.98 },
    "line_items": [
      { "sku": "SKU-001", "name": "Garnet Ring", "quantity": 2, "unit_price": 24.99 },
      { "sku": "SKU-002", "name": "Opal Pendant", "quantity": 1, "unit_price": 39.99 }
    ]
  }' | jq .

How line identity works (plus_line_id)

Your OMS probably doesn't have a unique per-line id (an item_id often repeats across lines of the same product). Protect+ solves this for you:

  • On create, Protect+ mints a stable, read-only id per line: plus_line_id (format li_<16 hex>). You can't set or change it — injected values are stripped.
  • On update, the id is carried forward as long as the line still matches (by product_id/sku) — so re-sending the whole order is safe and ids stay stable, the same way Shopify keeps a line item id across order edits.
  • This id is what returns are anchored to. You'll see it as line_id on the returnable endpoint and echo it back as fulfillmentLineItemId when filing a return.

Guard rail on updates

An update that would remove or re-key a line with an active return is rejected with 409 (code: LINE_HAS_ACTIVE_RETURN) — otherwise the return would be orphaned. Adjust quantities instead of dropping the line.

{
  "type": "https://api.protectplus.io/errors/conflict",
  "title": "Conflict",
  "status": 409,
  "detail": "line_items[0] has an active return and cannot be removed",
  "code": "LINE_HAS_ACTIVE_RETURN"
}

Attach a prepaid return label (optional)

If your OMS already creates the return label up front — for example a prepaid USPS label printed into the box at fulfillment (the ShopLC/Narvar pattern) — send it on return_label so Protect+ shows it to the customer on the return flow. Protect+ never generates or pays for this label; we only display what you give us.

curl -s -X PUT $BASE_URL/orders/LC72540387 \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "platform": "custom",
    "ordered_at": "2026-06-01T18:42:11.000Z",
    "currency": "USD",
    "customer": { "email": "[email protected]" },
    "amounts": { "subtotal": 89.98 },
    "line_items": [
      { "sku": "SKU-001", "name": "Garnet Ring", "quantity": 2, "unit_price": 24.99 }
    ],
    "return_label": {
      "tracking_number": "9202099990744534011077",
      "carrier": "USPS",
      "service": "Ground Advantage Returns",
      "label_url": "https://labels.example.com/r/abc.pdf",
      "format": "PDF",
      "included_in_package": true
    }
  }' | jq .

How it drives the customer-facing display, in priority order:

  1. label_url or qr_code_url present → the real label is shown/linked.
  2. otherwise a valid USPS-format tracking_number → a tracking barcode is rendered (a scan aid, not postage).
  3. otherwise (included_in_package and/or a non-barcodeable tracking number) → a "your prepaid return label is included in your original packaging" notice.

This is independent of the shipping-label callback: use return_label on the order when the label exists at order/fulfillment time; use the callback when your OMS issues the label after approving the return. A bare tracking number can't be turned into postage — send label_url/qr_code_url whenever you have it. See Create or Update an Order for the full field list.

Carrier values (for granular tracking)

When tracking_number and carrier are both present, Protect+ registers the
shipment with the carrier and shows a live, step-by-step return-tracking
timeline
(in transit → out for delivery → delivered) on both the customer
portal and the merchant return detail. carrier is free-form and
case-insensitive — we normalize common names to the carrier's tracking provider,
so any of these work:

CarrierAccepted values (examples)
USPSUSPS, United States Postal Service, US Postal Service
UPSUPS, United Parcel Service
FedExFedEx, Federal Express
DHLDHL, DHL Express, DHL eCommerce
Canada PostCanada Post, Postes Canada

Send the carrier that actually issued the tracking number. An unrecognized or
missing carrier doesn't break anything — the label still displays — but the
granular timeline won't be available, and the return falls back to the coarse
"In transit" stage. USPS is the most common OMS return carrier and is fully
supported.


3. Look up what's returnable

Before filing a return from your OMS, ask Protect+ which lines still have returnable quantity — you never have to track this yourself:

curl -s $BASE_URL/orders/LC72540387/returnable \
  -H "Authorization: Bearer $ACCESS_TOKEN" | jq .
{
  "success": true,
  "data": {
    "order_id": "6843f1c2...",
    "order_number": "LC72540387",
    "currency": "USD",
    "line_items": [
      {
        "line_id": "li_8c1f2e...",
        "sku": "SKU-001",
        "name": "Garnet Ring",
        "unit_price": 24.99,
        "ordered_quantity": 2,
        "returned_quantity": 1,
        "returnable_quantity": 1,
        "is_returnable": true,
        "returns": [{ "id": "6843f9ab...", "return_number": "#LC72540387-R1" }]
      }
    ]
  }
}

Read it as: 2 ordered, 1 already in a return, 1 still returnable. Portal returns and API returns both count against the same number, so a customer can never over-return by combining the two paths.

line_id is the order line's plus_line_id — echo it back as fulfillmentLineItemId in the next step.


4. Submit a return request (optional — API intake)

If your OMS files returns on the customer's behalf, POST /returns records a return request for merchant review. It does not buy a label or move money.

For Custom/OMS orders, it also creates the linked Return (the lifecycle record callbacks address) and returns its id as return_id. If the order's return_label carries a carrier + tracking_number (see §2), Protect+ registers that label with the carrier so the return's "In transit" step and granular carrier status update automatically as the parcel is scanned — no shipping-label callback needed for tracking. Recognised carrier values are listed under Carrier values (for granular tracking).

curl -s -X POST $BASE_URL/returns \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 3f8a2c44-9d1e-4b7a-a1c2-0e5f6d7c8b9a" \
  -d '{
    "orderId": "LC72540387",
    "returnLineItems": [
      {
        "fulfillmentLineItemId": "li_8c1f2e...",
        "quantity": 1,
        "returnReason": "DAMAGED",
        "returnReasonNote": "Stone loose on arrival"
      }
    ],
    "returnFromAdress": {
      "address1": "123 Main St",
      "city": "Austin",
      "country": "US",
      "zip": "78701"
    },
    "returnMethod": "REFUND",
    "shipmentReturnMethod": "SELF_SHIP"
  }' | jq .

Response (201):

{
  "success": true,
  "data": {
    "return_request": {
      "id": "6843fa01...",
      "return_id": "6843fb22...",
      "name": "#LC72540387-R2",
      "status": "PENDING",
      "created_at": "2026-06-11T05:40:00.000Z",
      "return_line_items_summary": [
        { "fulfillment_line_item_id": "li_8c1f2e...", "quantity": 1, "return_reason": "DAMAGED" }
      ]
    }
  }
}
⚠️

Use return_id for callbacks, not id. The top-level id is the return-request id and is not a valid callback target (404). return_id is the linked Return (the lifecycle record) — that is what the decision / shipping-label / refund callbacks address, and where carrier tracking lives. return_id is present for Custom/OMS orders and null when no Return was created.

Validation is server-side, so you don't need pre-checks:

You sendYou get back
A fulfillmentLineItemId that isn't on the order400 (UNKNOWN_LINES)
A quantity above the line's remaining returnable_quantity400 (OVER_RETURN)
Same Idempotency-Key + same body within 24 h (retry)the cached response — safe
Same Idempotency-Key + different body422

5. Handle portal returns — list, read, and report outcomes

When a customer files a return through the Protect+ portal, your OMS picks it up from GET /returns, decides, and reports back. Every return is numbered {order number}-R{n} (e.g. #LC72540387-R1) — the same reference your support team and the customer see.

5.1 Find returns and poll the right status

Which status to poll depends on auto-approve mode:

Merchant settingPoll forYour next callback
Auto-approve OFF (default)display_status: "EVALUATION"decisionshipping-labelrefund
Auto-approve ONdisplay_status: "APPROVED"shipping-labelrefund (skip decision)
# All returns, newest first
curl -s "$BASE_URL/returns?orderNumber=LC72540387" \
  -H "Authorization: Bearer $ACCESS_TOKEN" | jq .

# One return in full
curl -s $BASE_URL/returns/6843f9ab... \
  -H "Authorization: Bearer $ACCESS_TOKEN" | jq .

Drive your logic on display_status. The full lifecycle, including what each party sees:

display_statusWhat moves it thereCustomer portal showsMerchant dashboard shows
EVALUATIONCustomer files in the portal (auto-approve OFF)"Evaluation pending"Pending review
APPROVEDYour decision callback (APPROVED), or auto-approve at portal submit"Approved"Approved
EVALUATION_REJECTEDYour decision callback (REJECTED)"Rejected" + your noteRejected
IN_TRANSITYour shipping-label callback"In transit" + tracking + label linkIn transit
PROCESSEDYour refund callback"Processed"Processed

With auto-approve OFF, your decision callback is what moves a return from EVALUATION to APPROVED or EVALUATION_REJECTED. With auto-approve ON, portal submission already lands at APPROVED — your label and refund callbacks move it forward from there. Protect+ updates both surfaces and emails the customer automatically at each step.

5.2 The three callbacks

Send each one when the corresponding event happens in your OMS, in order: decision → shipping-label → refund. All three target the return id from §5.1 and only apply to platform: "custom" orders.

Decision — approve or reject:

curl -s -X POST $BASE_URL/returns/6843f9ab.../decision \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "decision": "APPROVED",
    "note": "Within the 30-day return window.",
    "externalReference": { "system": "AMS", "id": "RMA-88412" }
  }' | jq .

decision is APPROVED or REJECTED and is final through this API. On REJECTED, the customer sees your note — write it for them.

Shipping label — when your OMS issues it:

curl -s -X POST $BASE_URL/returns/6843f9ab.../shipping-label \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "carrier": "USPS",
    "trackingNumber": "9405511899560001234567",
    "labelUrl": "https://labels.example.com/RMA-88412.pdf",
    "trackingUrl": "https://tools.usps.com/go/TrackConfirmAction?tLabels=9405511899560001234567",
    "externalReference": { "system": "AMS", "id": "RMA-88412" }
  }' | jq .

Protect+ stores the label, marks the return IN_TRANSIT, and emails the customer the label and tracking link — you don't need to notify them yourself.

Refund — once the money moved:

curl -s -X POST $BASE_URL/returns/6843f9ab.../refund \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "refundAmount": 17.50,
    "currency": "USD",
    "deductions": 7.49,
    "externalRefundId": "REF-2026-019233",
    "executedAt": "2026-06-11T04:55:00.000Z"
  }' | jq .

The update is atomic — the return moves to PROCESSED and cannot be processed again.

5.3 Callback rules at a glance

Retries are always safe — each callback has a natural idempotency key:

RuleDetail
Order mattersdecisionshipping-labelrefund
Idempotency keysthe decision value / trackingNumber / externalRefundId — re-sending the same one returns 200
Conflictsa different decision or refund on a settled return returns 409
Unknown id404 — callbacks never create returns (are you using a return-request id by mistake?)
Wrong platform400 if the return is not on a custom order
Shared referencesend externalReference on every callback so both systems share one identity for the return

Response shape note: callback success and business-rejection bodies are passed through from the returns service verbatim as plain {"success": true|false, "message": "..."} JSON. Gateway-level errors (auth, validation, rate limit, unknown id) use RFC 7807 Problem Details like the rest of the API.

5.4 Auto-approve mode — callback contract

See Auto-approve mode at the top of this guide for when to use it and how it changes your polling. Callback specifics:

ActionResult
Portal submit with auto-approve ONReturn created as APPROVED — no decision needed
Send decision: APPROVED on an already-approved return200 (idempotent) — use this to attach externalReference
Send decision: REJECTED after auto-approval409 — the return is already approved
Send shipping-label / refundSame as manual-review mode — always required from your OMS
POST /returns (API intake from your OMS)Unaffected — always creates a return request for merchant review, never auto-approved

What Protect+ does not do (by design)

Build your OMS-side logic knowing these are yours to own:

  • No exchanges — OMS orders offer refund-type resolutions only (refund, store credit, gift card).
  • No shipping labels from Protect+ — your OMS supplies every label, either up front on the order (return_label, see §2) or after approval via the shipping-label callback. Protect+ only displays them.
  • No refunds from Protect+ — your OMS pays; the callback just records it.
  • Return-window enforcement is advisory — the portal applies the store's window rules to what the customer can select, but your approve/reject decision is the final gate.

Troubleshooting

SymptomLikely causeFix
Callback returns 404You used the top-level id (return-request id) from POST /returnsUse return_id from the POST /returns response, or a return id from GET /returns (§5.1)
409 LINE_HAS_ACTIVE_RETURN on PUT /ordersThe update drops or re-keys a line that has a returnKeep the line; adjust quantities instead
400 OVER_RETURN on POST /returnsQuantity exceeds the line's remaining returnableCheck GET /orders/{n}/returnable first
400 UNKNOWN_LINES on POST /returnsfulfillmentLineItemId isn't a line_id on this orderTake line_id from the returnable endpoint verbatim
409 on a callbackA different decision/refund is already recorded, or REJECTED sent after auto-approvalThe return is settled — treat the 409 as final state
Return stuck at EVALUATION but merchant says auto-approve is onSetting may not be enabled yet, or you're polling the wrong storeConfirm with merchant; poll for APPROVED instead when the setting is on
Return already APPROVED before your decision callbackAuto-approve is on for this storeSkip decision; send shipping-label when ready
422 on POST /returnsIdempotency-Key reused with a different bodyMint a fresh UUID per logical request
401 mid-runJWT expired (60 min)Re-mint via /auth/v1/token; refresh proactively
429Rate limitBack off and retry with jitter

Error handling

All non-callback errors use RFC 7807 Problem Details:

{
  "type": "https://api.protectplus.io/errors/validation",
  "title": "Bad Request",
  "status": 400,
  "detail": "returnLineItems[0].quantity exceeds returnable_quantity",
  "requestId": "...",
  "traceId": "..."
}

Common status codes:

StatusMeaning
400Validation / business rejection — check detail (e.g. over-return, unknown line id, non-custom platform)
401Missing or invalid Bearer token — re-mint via /auth/v1/token
403Return belongs to another store, or token scope mismatch
404Order or return not found for your store
409Conflict — line has an active return (orders), or a decision/refund is already recorded (callbacks)
422Body failed validation, or Idempotency-Key reused with a different body
429Rate limit — back off and retry

Next steps