Returns
Let customers of your store return items through Protect+ while your OMS stays in charge of every business decision — approvals, shipping labels, and refunds. This guide takes you from zero to a fully working integration.
Returns — Integration guide (OMS / custom platform)
Let customers of your store return items through Get Plus while your OMS stays in charge of every business decision — approvals, shipping labels, and refunds. This guide takes you from zero to a fully working integration.
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 | Get Plus return portal |
| Checking the store's return rules (window, excluded items, questions) | Get Plus return portal |
| Approving or rejecting the return | Your OMS (or Get Plus 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 | Get Plus (automatic) |
| Making sure the same item can't be returned twice | Get Plus (automatic) |
sequenceDiagram
participant LC as Your OMS
participant PP as Get Plus
participant CUS as Customer (Portal)
LC->>PP: PUT /orders/{n} — keep orders in sync
CUS->>PP: Submit return in portal
PP-->>LC: GET /returns — poll new returns + status
LC->>PP: POST /returns/{id}/decision — approve / reject
LC->>PP: POST /returns/{id}/shipping-label — label issued
LC->>PP: POST /returns/{id}/refund — money moved
PP-->>CUS: Dashboards + emails kept in sync
Note over LC,CUS: Auto-approve ON → skip the decision callback
Get Plus never buys a label, never issues a refund, and never overrides your decision. Your job as the integrator is two-way data flow:
You → Get Plus (keep data in sync)
PUT /orders/{n} push orders as they're created/changed
GET /orders/{n}/returnable-items see which lines can still be returned
POST /returns file a return on the customer's behalf (optional)
GET /returns/url deep-link a customer into the return portal (optional)
GET /returns poll portal-created returns + statuses
You → Get Plus (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 Get Plus 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 external_reference — 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 anexternal_reference- 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 Get Plus 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 (line_id)
line_id)Every order line has a stable line_id — the key that returns are anchored to. You read it from GET /orders/{n}/returnable-items (and the order detail) and echo it back as line_id when you file a return. You have two options, and you don't have to choose up front — if you do nothing, Get Plus just handles it:
Option A — let Get Plus mint it (default, zero work). If you don't send line_id, Get Plus assigns each line a li_<16 hex> id on create. The response reports line_id_source: protectplus. This is the right choice for most integrations — your OMS keys on its own order#/SKU and only needs the line_id value when filing a return.
Option B — supply your own line_id. Useful when your OMS already has a stable per-line ref (e.g. ShopLC's LC99100000) you'd rather keep. Put it on every line at create time. It's honoured only when every line carries a non-empty, order-unique line_id — then Get Plus keeps your values verbatim and reports line_id_source: partner. If any line omits it, or two lines share a value, it's all-or-nothing: Get Plus ignores them and mints li_<hex> ids for the whole order (line_id_source: protectplus).
Where "create" happens: the first time Get Plus sees the order — whether you send it via
POST /ordersor the firstPUT /orders/{n}for that order number. Both accept yourline_id.
On every later update (PUT /orders/{n}) the id is carried forward: a line that still matches an existing line (by product_id/sku) keeps its line_id; only genuinely-new lines get a new one. This mirrors how Shopify keeps a line item id stable across order edits, so a return already filed against a line stays linked. The id is fixed at create — a line_id (or line_id_source) sent on a later PUT is ignored, and you can safely re-send the whole order.
Whichever option you use, the contract is the same: read line_id (and line_id_source) from the returnable view, echo line_id back on POST /returns.
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 — attach it with PUT /orders/{order_number}/return-label so Get Plus shows it to the customer on the return flow. Get Plus never generates or pays for this label; we only display what you give us. The label is stored separately from the order, so it is not part of the POST/PUT /orders body.
curl -s -X PUT $BASE_URL/orders/LC72540387/return-label \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"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 .The call is idempotent — a second PUT replaces the stored label. Clear a label with DELETE /orders/{order_number}/return-label (returns 200 with deleted: false when none was attached). Only OMS (Custom) orders are accepted (409 otherwise); unknown orders return 404.
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.
The merchant-provided label is always first priority when a return is created. (Get Plus-generated return labels for a customer's 2nd/3rd return may be offered in the future; not available today.)
This is independent of the shipping-label callback: attach the return label when it 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.
Carrier values (for granular tracking)
When tracking_number and carrier are both present, Get Plus 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 Get Plus which lines still have returnable quantity — you never have to track this yourself:
curl -s $BASE_URL/orders/LC72540387/returnable-items \
-H "Authorization: Bearer $ACCESS_TOKEN" | jq .{
"success": true,
"data": {
"order_id": "6843f1c2...",
"order_number": "LC72540387",
"currency": "USD",
"line_items": [
{
"line_id": "li_8c1f2e...",
"line_id_source": "protectplus",
"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 line_id — echo it back as line_id 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 a return label has been attached for the order (via PUT /orders/{order_number}/return-label, see §2) with a carrier + tracking_number, Get Plus 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).
You can also attach the prepaid label in the same request by including a return_label object in the POST /returns body (same shape as §2). The label shown/tracked for the return is resolved in priority order: (1) the request's return_label, else (2) the order's prepaid label from §2, else (3) none. A return_label sent here applies to that return only and never overwrites the order-level label.
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 '{
"order_id": "LC72540387",
"return_line_items": [
{
"line_id": "li_8c1f2e...",
"quantity": 1,
"return_reason": "DAMAGED",
"return_reason_note": "Stone loose on arrival"
}
],
"return_from_address": {
"address1": "123 Main St",
"city": "Austin",
"country": "US",
"zip": "78701"
},
"return_method": "REFUND",
"shipment_return_method": "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": [
{ "line_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 line_id 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 |
Deep link: open the return portal without an OTP
If your integration has already authenticated the customer — a storefront, an OMS, or customer-service tooling, on any platform (Shopify, WordPress, custom, ...) — you don't have to send them through the portal's email OTP login, and you don't need the customer's email address at all. GET /returns/url returns a short-lived, order-scoped link that opens the branded Get Plus return portal directly in the return flow for that order. Redirect the customer to it; they select items, pick a reason and resolution, and submit — no code, no email.
curl -s "$BASE_URL/returns/url?order_number=LC72540387" \
-H "Authorization: Bearer $ACCESS_TOKEN" | jq .Response (200):
{
"success": true,
"data": {
"url": "https://orders.protectplus.io/return/6843f1c2...?token=eyJhbGciOi...",
"expires_at": "2026-07-14T15:00:00.000Z"
}
}How it behaves:
| You send | You get back |
|---|---|
| An order in your store with returnable items | 200 with the portal url |
| An order number that doesn't exist in your store | 404 |
| An order with nothing left to return | 422 |
Rules of the road:
- Request fresh, request late. The embedded token expires at
expires_at(about 1 hour). Request a new link each time the customer starts a return rather than storing links. - Server-to-server only. Call this endpoint from your backend. Never embed your API key in a client application — your client asks your backend, your backend asks Get Plus.
- Order-scoped access. The link grants access to that order's return flow only. Your system vouches for the customer (you've already authenticated them), the same trust model as filing the return via
POST /returnsyourself. - Returns submitted through the portal show up in
GET /returnspolling exactly like any other portal return (§5) — approvals, labels, and refunds flow through the same callbacks.
5. Handle portal returns — list, read, and report outcomes
When a customer files a return through the Get Plus 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?order_number=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:
stateDiagram-v2
[*] --> EVALUATION: Customer files (auto-approve OFF)
[*] --> APPROVED: Customer files (auto-approve ON)
EVALUATION --> APPROVED: decision = APPROVED
EVALUATION --> EVALUATION_REJECTED: decision = REJECTED
APPROVED --> IN_TRANSIT: shipping-label callback
IN_TRANSIT --> PROCESSED: refund callback
EVALUATION_REJECTED --> [*]
PROCESSED --> [*]
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. Get Plus 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.",
"external_reference": { "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",
"tracking_number": "9405511899560001234567",
"label_url": "https://labels.example.com/RMA-88412.pdf",
"tracking_url": "https://tools.usps.com/go/TrackConfirmAction?tLabels=9405511899560001234567",
"external_reference": { "system": "AMS", "id": "RMA-88412" }
}' | jq .Get Plus 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 '{
"refund_amount": 17.50,
"currency": "USD",
"deductions": 7.49,
"external_refund_id": "REF-2026-019233",
"executed_at": "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 / tracking_number / external_refund_id — 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 external_reference 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 external_reference |
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 Get Plus 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 Get Plus — your OMS supplies every label, either up front via
PUT /orders/{order_number}/return-label(see §2) or after approval via theshipping-labelcallback. Get Plus only displays them. - No refunds from Get Plus — 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-items first |
400 UNKNOWN_LINES on POST /returns | line_id isn't a known line 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) | Request a new one 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": "return_line_items[0].quantity exceeds returnable_quantity",
"request_id": "...",
"trace_id": "..."
}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 — request a new one 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
Updated about 1 month ago