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

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 returnGet Plus return portal
Checking the store's return rules (window, excluded items, questions)Get Plus return portal
Approving or rejecting the returnYour OMS (or Get Plus automatically when auto-approve is on)
Providing the shipping labelYour OMS
Paying the refundYour OMS
Keeping the customer and merchant dashboards up to dateGet Plus (automatic)
Making sure the same item can't be returned twiceGet 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 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 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:

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 external_reference
  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 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)

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 /orders or the first PUT /orders/{n} for that order number. Both accept your line_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:

  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.

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:

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 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" }
      ]
    }
  }
}
⚠️

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 line_id 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

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 sendYou get back
An order in your store with returnable items200 with the portal url
An order number that doesn't exist in your store404
An order with nothing left to return422

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 /returns yourself.
  • Returns submitted through the portal show up in GET /returns polling 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 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?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_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. 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:

RuleDetail
Order mattersdecisionshipping-labelrefund
Idempotency keysthe decision value / tracking_number / external_refund_id — 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 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:

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 external_reference
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 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 the shipping-label callback. 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

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-items first
400 UNKNOWN_LINES on POST /returnsline_id isn't a known line 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)Request a new one 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": "return_line_items[0].quantity exceeds returnable_quantity",
  "request_id": "...",
  "trace_id": "..."
}

Common status codes:

StatusMeaning
400Validation / business rejection — check detail (e.g. over-return, unknown line id, non-custom platform)
401Missing or invalid Bearer token — request a new one 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


Did this page help you?