Returns
Targeting a different feature area (Tracking, Claims, etc.)? Start at the API Reference — each surface has its own guide.
Base URLs
| Environment | URL |
|---|---|
| Production | https://api.protectplus.io |
| Dev / sandbox | https://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:
| Step | Who handles it |
|---|---|
| Customer picks items and submits the return | Protect+ return portal |
| Checking the store's return rules (window, excluded items, questions) | Protect+ return portal |
| Approving or rejecting the return | Your OMS (or Protect+ automatically when auto-approve is on) |
| Providing the shipping label | Your OMS |
| Paying the refund | Your OMS |
| Keeping the customer and merchant dashboards up to date | Protect+ (automatic) |
| Making sure the same item can't be returned twice | Protect+ (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 submit | EVALUATION | APPROVED |
Your OMS must send decision? | Yes — approve or reject | No — skip straight to label/refund |
| Label + refund callbacks | Required | Still required |
POST /returns (API intake) | Unaffected — still creates a return request for review | Unaffected |
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:
| Object | Where you get its id | Callbacks accept it? |
|---|---|---|
| Return request (intake record) | POST /returns → data.return_request.id | ❌ No — 404 |
| Return (lifecycle record) | POST /returns → data.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:
- Authenticate — exchange your API key for a JWT (§1)
- Push your orders —
PUT /orders/{n}whenever an order is created or changed (§2) - Confirm auto-approve mode — ask the merchant (see Auto-approve mode above). Your polling filter and whether you send a
decisioncallback depend on this.
Then pick the path that matches the merchant's setting:
Manual review (default — auto-approve OFF)
- Poll
GET /returnsfordisplay_status: "EVALUATION" - Send
POST /returns/{id}/decision—APPROVEDorREJECTED - Send
POST /returns/{id}/shipping-labelonce your OMS issues it - Send
POST /returns/{id}/refundonce the money moved
Auto-approve ON
- Poll
GET /returnsfordisplay_status: "APPROVED"(returns skipEVALUATION) Send decision— not needed; optional if you want to attach anexternalReference- Send
POST /returns/{id}/shipping-labelonce your OMS issues it - Send
POST /returns/{id}/refundonce 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)
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(formatli_<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_idon the returnable endpoint and echo it back asfulfillmentLineItemIdwhen 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:
label_urlorqr_code_urlpresent → the real label is shown/linked.- otherwise a valid USPS-format
tracking_number→ a tracking barcode is rendered (a scan aid, not postage). - otherwise (
included_in_packageand/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:
| Carrier | Accepted values (examples) |
|---|---|
| USPS | USPS, United States Postal Service, US Postal Service |
| UPS | UPS, United Parcel Service |
| FedEx | FedEx, Federal Express |
| DHL | DHL, DHL Express, DHL eCommerce |
| Canada Post | Canada 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" }
]
}
}
}
Usereturn_idfor callbacks, notid. The top-levelidis the return-request id and is not a valid callback target (404).return_idis the linked Return (the lifecycle record) — that is what thedecision/shipping-label/refundcallbacks address, and where carrier tracking lives.return_idis present for Custom/OMS orders andnullwhen no Return was created.
Validation is server-side, so you don't need pre-checks:
| You send | You get back |
|---|---|
A fulfillmentLineItemId that isn't on the order | 400 (UNKNOWN_LINES) |
A quantity above the line's remaining returnable_quantity | 400 (OVER_RETURN) |
Same Idempotency-Key + same body within 24 h (retry) | the cached response — safe |
Same Idempotency-Key + different body | 422 |
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 setting | Poll for | Your next callback |
|---|---|---|
| Auto-approve OFF (default) | display_status: "EVALUATION" | decision → shipping-label → refund |
| Auto-approve ON | display_status: "APPROVED" | shipping-label → refund (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_status | What moves it there | Customer portal shows | Merchant dashboard shows |
|---|---|---|---|
EVALUATION | Customer files in the portal (auto-approve OFF) | "Evaluation pending" | Pending review |
APPROVED | Your decision callback (APPROVED), or auto-approve at portal submit | "Approved" | Approved |
EVALUATION_REJECTED | Your decision callback (REJECTED) | "Rejected" + your note | Rejected |
IN_TRANSIT | Your shipping-label callback | "In transit" + tracking + label link | In transit |
PROCESSED | Your 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:
| Rule | Detail |
|---|---|
| Order matters | decision → shipping-label → refund |
| Idempotency keys | the decision value / trackingNumber / externalRefundId — re-sending the same one returns 200 |
| Conflicts | a different decision or refund on a settled return returns 409 |
| Unknown id | 404 — callbacks never create returns (are you using a return-request id by mistake?) |
| Wrong platform | 400 if the return is not on a custom order |
| Shared reference | send 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:
| Action | Result |
|---|---|
| Portal submit with auto-approve ON | Return created as APPROVED — no decision needed |
Send decision: APPROVED on an already-approved return | 200 (idempotent) — use this to attach externalReference |
Send decision: REJECTED after auto-approval | 409 — the return is already approved |
Send shipping-label / refund | Same 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 theshipping-labelcallback. 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
| Symptom | Likely cause | Fix |
|---|---|---|
Callback returns 404 | You used the top-level id (return-request id) from POST /returns | Use return_id from the POST /returns response, or a return id from GET /returns (§5.1) |
409 LINE_HAS_ACTIVE_RETURN on PUT /orders | The update drops or re-keys a line that has a return | Keep the line; adjust quantities instead |
400 OVER_RETURN on POST /returns | Quantity exceeds the line's remaining returnable | Check GET /orders/{n}/returnable first |
400 UNKNOWN_LINES on POST /returns | fulfillmentLineItemId isn't a line_id on this order | Take line_id from the returnable endpoint verbatim |
409 on a callback | A different decision/refund is already recorded, or REJECTED sent after auto-approval | The return is settled — treat the 409 as final state |
Return stuck at EVALUATION but merchant says auto-approve is on | Setting may not be enabled yet, or you're polling the wrong store | Confirm with merchant; poll for APPROVED instead when the setting is on |
Return already APPROVED before your decision callback | Auto-approve is on for this store | Skip decision; send shipping-label when ready |
422 on POST /returns | Idempotency-Key reused with a different body | Mint a fresh UUID per logical request |
401 mid-run | JWT expired (60 min) | Re-mint via /auth/v1/token; refresh proactively |
429 | Rate limit | Back 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:
| Status | Meaning |
|---|---|
| 400 | Validation / business rejection — check detail (e.g. over-return, unknown line id, non-custom platform) |
| 401 | Missing or invalid Bearer token — re-mint via /auth/v1/token |
| 403 | Return belongs to another store, or token scope mismatch |
| 404 | Order or return not found for your store |
| 409 | Conflict — line has an active return (orders), or a decision/refund is already recorded (callbacks) |
| 422 | Body failed validation, or Idempotency-Key reused with a different body |
| 429 | Rate limit — back off and retry |
Next steps
- API Keys and Auth — token lifecycle, rotation, revocation
- Order Tracking — push outbound shipment tracking
- API Reference — full OpenAPI spec for every field