This playbook covers the end-to-end returns/RMA flow across the six system categories involved in a typical returns operation. The "source of truth" shifts at each stage: the ecommerce platform owns the customer-facing return request, the CRM owns the case/service interaction, the WMS owns the physical receiving and inspection, and the ERP owns the financial settlement (credit memo, refund, inventory valuation). Integration middleware (iPaaS or custom) orchestrates the handoffs.
Not covered: vendor/supplier returns (use Procure-to-Pay), warranty claim processing with repair depot workflows, or recalls with serial-number-level tracking.
| System | Role | API Surface | Direction |
|---|---|---|---|
| Ecommerce (Shopify / Magento / BigCommerce) | Return initiation, customer-facing status, refund execution | REST / GraphQL / Webhooks | Outbound (triggers) + Inbound (refund) |
| Returns Portal (Loop / ReturnGO / AfterShip) | Self-service return request, label generation, reason capture | REST / Webhooks | Outbound (return data) |
| CRM (Salesforce Service Cloud) | Case management, agent-assisted returns, RMA approval | REST v62.0 / Platform Events | Bidirectional |
| ERP — NetSuite | Return Authorization, Item Receipt, Credit Memo, Customer Refund, GL posting | SuiteTalk SOAP / REST / SuiteQL | Inbound (master) |
| ERP — SAP S/4HANA | Returns Order (RE), Returns Delivery, Credit Memo, FI posting | OData v4 / BAPI / IDoc | Inbound (master) |
| ERP — Dynamics 365 | Return Order, disposition codes, Credit Note, inventory adjustment | OData v4 / Business Events | Inbound (master) |
| WMS | Receiving, inspection, disposition, restock / quarantine / scrap | REST / EDI 180 | Bidirectional |
| Payment Gateway (Stripe / Adyen / PayPal) | Refund execution to original payment method | REST | Inbound (refund call) |
| System | Return Object | Create API | Status Updates | Webhook/Event Support | Bulk? |
|---|---|---|---|---|---|
| Shopify | Refund / Return | POST /admin/api/2025-01/orders/{id}/refunds.json | GET /admin/api/.../refunds.json | return_created, refund_created | No |
| Loop Returns | Return | POST /api/v1/warehouse/return | Webhooks on status change | return.created, return.received, return.resolved | No |
| Salesforce OM | ReturnOrder + ReturnOrderLineItem | POST /services/data/v62.0/sobjects/ReturnOrder | PATCH .../ReturnOrder/{id} | Platform Events | No |
| NetSuite | Return Authorization (RA) | POST /record/v1/returnAuthorization | PATCH .../returnAuthorization/{id} | SuiteScript User Events | SuiteTalk async |
| SAP S/4HANA | Returns Order (doc type RE) | POST /API_SALES_ORDER_SRV/A_SalesOrder | PATCH .../A_SalesOrder('{id}') | Business Events / IDoc | BAPI batch |
| Dynamics 365 | SalesReturnOrder | POST /data/SalesReturnOrderHeaders | PATCH .../SalesReturnOrderHeaders('{id}') | Business Events | Data Entity batch |
| System | Limit Type | Value | Impact on Returns | Notes |
|---|---|---|---|---|
| Shopify | API calls | 40 req/s (REST), 50 cost points/s (GraphQL) | Burst-create refunds may throttle | Use GraphQL for bulk queries |
| Salesforce | API calls | 100,000/24h (Enterprise) | Shared with all integrations | Monitor with /limits endpoint |
| Salesforce | Governor limits | 100 SOQL / 150 DML per transaction | ReturnOrder triggers cascade | Bulkify all triggers |
| NetSuite | Concurrent requests | 5 default, 10+ SuiteCloud Plus | Post-holiday spikes cause queuing | Implement request queuing |
| NetSuite | Governance units | 10,000 (scheduled), 1,000 (user event) | Credit Memo creation ~50-100 units | Chain scripts if needed |
| SAP S/4HANA | x-csrf-token | Required per session | Every write needs fresh token | Cache token, refresh on 403 |
| Dynamics 365 | Throttling | 6,000 req/5 min per user | Batch returns can hit limit | Use $batch OData requests |
| Return Volume | Recommended Pattern | Key Risk |
|---|---|---|
| < 100/day | Real-time event-driven | Over-engineering |
| 100-1,000/day | Event-driven + batch financial settlement | Post-holiday spikes to 5-10x |
| > 1,000/day | Full async with message queue + batch ERP posting | Queue depth monitoring, DLQ handling |
| System | Auth Method | Return-Specific Notes |
|---|---|---|
| Shopify | OAuth 2.0 / API key | Refund scope requires write_orders |
| Loop Returns | API Key (X-Authorization header) | Warehouse API uses separate key |
| Salesforce | OAuth 2.0 JWT bearer | Requires Order Management Operator permission set |
| NetSuite | TBA or OAuth 2.0 | Returns role permission required |
| SAP S/4HANA | OAuth 2.0 + x-csrf-token | Communication arrangement for API_SALES_ORDER_SRV |
| Dynamics 365 | Azure AD OAuth 2.0 | user_impersonation scope required |
START — Customer initiates a return
|
+-- Where was the return initiated?
| +-- Ecommerce self-service portal (Shopify/Loop)
| | +-- Webhook fires return.created --> iPaaS
| +-- Agent-assisted (Salesforce Service Cloud)
| | +-- Agent creates ReturnOrder via Service Console --> Platform Event
| +-- In-store POS
| +-- POS creates return + immediate refund --> OMS sync
|
+-- Does the item need to ship back?
| +-- YES (ship-back return)
| | +-- Generate return label --> Create WMS inbound ASN
| | +-- Create ERP Return Authorization (Pending Receipt)
| | +-- WAIT for warehouse receipt confirmation
| | | +-- WMS confirms receipt --> inspection
| | | +-- Disposition: Resaleable --> restock
| | | +-- Disposition: Damaged --> quarantine/scrap
| | | +-- Disposition: Wrong item --> manual review
| | +-- After receipt + inspection:
| | +-- Create Credit Memo + Customer Refund in ERP
| | +-- Execute payment gateway refund
| | +-- Update ecommerce + CRM status
| |
| +-- NO (returnless refund / keep-the-item)
| +-- Skip WMS --> Credit Memo directly --> Payment refund
|
+-- Exchange instead of refund?
+-- Process return (same receiving flow)
+-- Create new sales order for replacement
+-- Handle price difference
| Step | Source System | Action | Target System | Data Objects | Failure Handling |
|---|---|---|---|---|---|
| 1. Return Request | Ecommerce / Loop | Customer submits return | iPaaS | Return request, line items, reason | Retry 3x, then DLQ |
| 2. RMA Creation | iPaaS | Create Return Authorization | ERP | RA linked to original SO/invoice | Validate SO exists first |
| 3. Case Creation | iPaaS | Create/update service case | CRM | Case with RMA reference | Optional for self-service |
| 4. Label Generation | Returns Portal | Generate prepaid return label | Carrier API | Tracking number, label PDF | Fallback to manual label |
| 5. ASN Creation | iPaaS | Create inbound shipment notice | WMS | Expected items, RMA number | WMS must accept before ship |
| 6. Warehouse Receipt | WMS | Scan + receive returned items | iPaaS | Receipt confirmation, qty received | Partial receipt triggers partial flow |
| 7. Inspection | WMS | Inspect condition, assign disposition | iPaaS | Disposition code | Unknown disposition → manual queue |
| 8. Inventory Update | iPaaS | Post inventory movement | ERP | Item Receipt / Goods Receipt | Idempotent: check if already posted |
| 9. Credit Memo | iPaaS | Create credit memo | ERP | CM linked to RA + original invoice | Use original FX rate |
| 10. Customer Refund | iPaaS | Create customer refund record | ERP | Refund linked to Credit Memo | Match original payment method |
| 11. Payment Refund | iPaaS | Execute refund to payment method | Payment Gateway | Refund amount, original txn ID | Check status before retry |
| 12. Status Update | iPaaS | Update order/return status | Ecommerce + CRM | "Refunded" status | Idempotent: check current status |
Configure webhook subscriptions on your ecommerce platform to receive return events in real-time. [src6]
// Shopify webhook payload for return creation
// Subscribe: POST /admin/api/2025-01/webhooks.json
// Topic: "returns/request"
{
"return": {
"id": 123456789,
"order_id": 987654321,
"status": "requested",
"return_line_items": [{
"fulfillment_line_item_id": 222,
"quantity": 1,
"return_reason": "DEFECTIVE",
"customer_note": "Screen cracked on arrival"
}]
}
}
Verify: Shopify Admin > Settings > Notifications > Webhooks — HTTP 200 from endpoint within 5s.
Transform the return request into the ERP's native return object. [src1]
// NetSuite: Create RA via REST API
// POST https://{accountId}.suitetalk.api.netsuite.com/services/rest/record/v1/returnAuthorization
{
"entity": { "id": "12345" },
"createdFrom": { "id": "67890" }, // Original Sales Order ID
"memo": "RMA-2026-00456 via Loop Returns",
"item": { "items": [{
"item": { "id": "1001" },
"quantity": 1,
"rate": 49.99,
"orderLine": 1
}]}
}
// Response: 204 No Content, Location header has new RA ID
Verify: GET /record/v1/returnAuthorization/{id} → status "Pending Receipt".
Notify the warehouse of the expected return with RMA number and expected items. [src2]
// POST https://wms.example.com/api/v1/inbound-shipments
{
"shipment_type": "RETURN",
"rma_number": "RMA-2026-00456",
"expected_date": "2026-03-14",
"carrier_tracking": "1Z999AA10123456784",
"lines": [{
"sku": "WIDGET-BLU-LG",
"expected_qty": 1,
"inspection_required": true,
"disposition_options": ["RESTOCK", "QUARANTINE", "SCRAP"]
}]
}
Verify: WMS returns shipment ID and status "AWAITING_RECEIPT".
WMS confirms receipt and inspection disposition, driving downstream financial processing. [src2]
// WMS receipt + inspection callback
// POST https://ipaas.example.com/webhooks/wms/receipt
{
"event": "return.inspected",
"rma_number": "RMA-2026-00456",
"received_date": "2026-03-12",
"lines": [{
"sku": "WIDGET-BLU-LG",
"received_qty": 1,
"disposition": "RESTOCK",
"condition_notes": "Original packaging, no damage"
}]
}
Verify: iPaaS logs show receipt event processed. ERP RA status → "Pending Refund/Credit".
Two-step process: Item Receipt (inventory) then Credit Memo (GL). [src1, src5]
// Step 1: Item Receipt from RA
// POST /services/rest/record/v1/itemReceipt
{ "createdFrom": { "id": "RA_ID" }, "item": { "items": [{
"item": { "id": "1001" }, "quantity": 1, "location": { "id": "5" }, "itemReceive": true
}]}}
// Step 2: Credit Memo
// POST /services/rest/record/v1/creditMemo
{ "entity": { "id": "12345" }, "createdFrom": { "id": "RA_ID" }, "item": { "items": [{
"item": { "id": "1001" }, "quantity": 1, "rate": 49.99
}]}}
// Step 3: Customer Refund
// POST /services/rest/record/v1/customerRefund
{ "entity": { "id": "12345" }, "apply": { "items": [{
"doc": { "id": "CREDIT_MEMO_ID" }, "amount": 49.99, "apply": true
}]}}
Verify: RA status = "Closed". Credit Memo posted. GL: AR credited, Sales Returns debited.
After ERP financial documents post, refund to original payment method. [src5]
// Stripe refund to original charge
const refund = await stripe.refunds.create({
charge: 'ch_original_charge_id',
amount: 4999, // cents
reason: 'requested_by_customer',
metadata: { rma_number: 'RMA-2026-00456', erp_credit_memo: 'CM-12345' }
});
// Verify: refund.status === 'succeeded'
Verify: Stripe refund status = "succeeded". If "pending", poll every 60s up to 5 times.
Close the loop by updating all customer-facing systems. [src2]
// Salesforce: Close the service case
// PATCH /services/data/v62.0/sobjects/Case/{caseId}
{ "Status": "Closed", "Resolution_Type__c": "Refund Processed",
"RMA_Number__c": "RMA-2026-00456", "Refund_Amount__c": 49.99 }
Verify: Shopify order shows "Refunded". Salesforce case = "Closed".
| Source Field (Ecommerce) | NetSuite | SAP S/4HANA | D365 | Gotcha |
|---|---|---|---|---|
| order_id | createdFrom (SO ref) | ReferenceSDDocument (VBELN) | SalesId | Must resolve to internal ID |
| return_reason | memo + custom field | ReasonForRejection (ABGRU) | ReturnReasonCodeId | Reason codes differ per system |
| line_item.sku | item.id (internal ID) | Material (MATNR) | ItemNumber | NetSuite needs SuiteQL lookup |
| line_item.quantity | quantity | OrderQuantity | ReturnInventoryQuantity | SAP uses sales unit of measure |
| refund_amount | rate (per unit) | NetAmount (NETWR) | SalesPrice | Must match original currency/rate |
| customer_email | entity (Customer ref) | SoldToParty (KUNNR) | CustAccount | Cross-system ID mapping required |
| tracking_number | custom field | HandlingUnitExternalID | TrackingNumber | Not native on NetSuite RA |
| disposition_code | custom field | MovementType (BWART) | InventDispositionCodeId | SAP: 541/542/551 |
| System | Code | Meaning | Resolution |
|---|---|---|---|
| NetSuite | INVALID_KEY_OR_REF | Referenced record not found | SuiteQL lookup to validate SO exists |
| NetSuite | RCRD_HAS_BEEN_CHANGED | Optimistic lock conflict | GET fresh record, then PATCH |
| SAP | BUSI_EXCEPTION | Business rule violation | Check partner function / pricing |
| SAP | 403 Forbidden | CSRF token expired | Fetch new x-csrf-token and retry |
| Salesforce | INVALID_TYPE | ReturnOrder sObject not found | Verify Order Management license |
| Salesforce | ENTITY_IS_LOCKED | Record in approval process | Wait for approval or admin bypass |
| Shopify | 422 Unprocessable | Refund exceeds capturable amount | Validate refund ≤ original minus prior refunds |
| Stripe | charge_already_refunded | Duplicate refund attempt | Check refund list before creating |
Webhook delivery confirmation with retry + daily reconciliation job. [src2]Gate refund on WMS inspection completion event only. [src2]Use WMS received quantities, not RA expected quantities, for credit memo. [src5]Idempotency check — query for existing CM linked to RA before creation. [src1]Explicitly pass exchange rate from original invoice. [src4]Maintain mapping table with QUARANTINE as default fallback. [src7]// BAD — Refund triggers when carrier marks delivered to warehouse
// Item hasn't been inspected. Could be empty box, wrong item, or damaged.
on('tracking.delivered_to_warehouse', async (event) => {
await erp.createCreditMemo(event.rma_id); // Too early!
await stripe.refunds.create({ charge: event.charge_id }); // Money gone!
});
// GOOD — Refund fires only after warehouse confirms receipt AND inspection
on('wms.inspection_completed', async (event) => {
if (event.disposition === 'REJECTED') {
await notifyCustomer(event.rma_id, 'Return rejected');
return; // No refund
}
const itemReceipt = await erp.createItemReceipt(event.rma_id, event.received_lines);
const creditMemo = await erp.createCreditMemo(event.rma_id, event.received_lines);
await stripe.refunds.create({
charge: event.charge_id, amount: creditMemo.total_cents
});
});
// BAD — Refund full order for partial return
const refundAmount = originalOrder.total; // Wrong for partial returns!
await stripe.refunds.create({ charge: chargeId, amount: refundAmount * 100 });
// GOOD — Sum returned lines at original per-unit price
const refundAmount = returnedLines.reduce((sum, line) =>
sum + (line.original_unit_price * line.returned_quantity), 0);
const taxRefund = refundAmount * originalOrder.tax_rate;
await stripe.refunds.create({
charge: chargeId, amount: Math.round((refundAmount + taxRefund) * 100)
});
// BAD — Timeout retry creates duplicate Credit Memo
async function processReturn(rmaId) {
const cm = await netsuite.create('creditMemo', { createdFrom: rmaId });
// If timeout but actually succeeded, retry = SECOND credit memo
}
// GOOD — Check for existing CM before creating
async function processReturn(rmaId) {
const existing = await netsuite.suiteql(
`SELECT id FROM transaction WHERE createdfrom = ${rmaId} AND type = 'CustCred'`
);
if (existing.items.length > 0) return existing.items[0];
return await netsuite.create('creditMemo', { createdFrom: rmaId });
}
Implement returnless path that skips steps 3-8, goes directly to Credit Memo. [src6]Use NetSuite RA Exchange option or SAP replacement delivery linked to returns order. [src1]Store mapping in configurable table (ERP custom list or iPaaS lookup). [src7]Daily reconciliation comparing WMS receipts against ERP item receipts. Alert on discrepancies > 0. [src2]Use ERP tax engine (SuiteTax, SAP Tax, Avalara/Vertex) — never calculate tax manually in integration. [src1]Check period status before posting; if closed, post to current period with original reference. [src4]# NetSuite: Check RA status and linked documents (SuiteQL)
curl -X POST "https://{accountId}.suitetalk.api.netsuite.com/services/rest/query/v1/suiteql" \
-H "Authorization: OAuth ..." \
-d '{"q": "SELECT id, tranid, status FROM transaction WHERE type = '\''RtnAuth'\'' AND tranid = '\''RA-12345'\''"}'
# NetSuite: Find all follow-on documents for an RA
curl -X POST ".../suiteql" \
-d '{"q": "SELECT id, tranid, type, status FROM transaction WHERE createdfrom = 12345"}'
# Salesforce: Check ReturnOrder status
curl "https://{instance}.salesforce.com/services/data/v62.0/query?q=SELECT+Id,Status,TotalAmount+FROM+ReturnOrder+WHERE+Id='\''0RR...'\''" \
-H "Authorization: Bearer {token}"
# SAP: Check returns order status
curl "https://{host}/sap/opu/odata/sap/API_SALES_ORDER_SRV/A_SalesOrder('{id}')?$select=OverallSDProcessStatus" \
-H "Authorization: Bearer {token}"
# Shopify: List refunds for an order
curl "https://{store}.myshopify.com/admin/api/2025-01/orders/{order_id}/refunds.json" \
-H "X-Shopify-Access-Token: {token}"
# Stripe: Check refund status
curl "https://api.stripe.com/v1/refunds/{refund_id}" -u "sk_live_...:"
| System | Current Version | Returns API Status | Key Changes |
|---|---|---|---|
| Shopify Admin API | 2025-01 | GA | Returns resource added 2023-07 (separate from Refunds) |
| NetSuite REST API | 2025.2 | GA | RA fully in REST since 2023.1; SuiteTalk SOAP not deprecated |
| SAP S/4HANA | 2408 | GA | Advanced Returns Management (ARM) in SD Fiori |
| Salesforce OM | v62.0 (Winter '26) | GA | Managed RMA workflow enhanced Winter '26 |
| Dynamics 365 F&SCM | 10.0.40 | GA | Enhanced disposition codes for WMS-only mode |
| Use This Playbook When | Don't Use When | Use Instead |
|---|---|---|
| Processing customer returns across ecommerce + ERP + WMS | Handling vendor/supplier returns | P2P Integration |
| Returns volume > 50/day requiring automation | < 10 returns/day (manual is fine) | Manual ERP entry |
| Multi-channel returns (online + in-store) | Single-channel, single-system | ERP native returns module |
| Need financial audit trail linking return to original sale | Simple exchange without financial impact | Ecommerce native exchange |
| Operating across multiple ERPs or migrating | Single ERP, no ecommerce integration | ERP vendor documentation |
| Capability | NetSuite | SAP S/4HANA | Dynamics 365 F&SCM | Notes |
|---|---|---|---|---|
| Return Object | Return Authorization (RA) | Returns Order (RE doc type) | Sales Return Order | All non-posting until follow-on docs |
| API Create | REST + SuiteTalk SOAP | OData (API_SALES_ORDER_SRV) | OData (SalesReturnOrderHeaders) | SAP requires OrderType=RE |
| Receiving | Item Receipt | Returns Delivery (doc 532) | Item Arrival Journal | NetSuite: initialize from RA |
| Credit | Credit Memo | CM Request + Credit Memo | Credit Note | SAP has extra step |
| Refund | Customer Refund record | AR clearing (F-32) | Customer Payment Journal | NetSuite most explicit |
| Disposition Codes | Custom field/list | Movement Types (541/542/551) | InventDispositionCode entity | SAP most granular |
| Exchange Support | Native (RA Exchange option) | Replacement delivery | Exchange order | NetSuite simplest |
| Multi-Currency | exchangeRate field on CM | KURRF from billing doc | ExchRate on journal | All require original-rate |
| Inspection | Custom workflow / SuiteScript | QM integration (QA01/QA02) | Quality management module | SAP most mature |
| Partial Returns | Line-level on RA | Line-level on returns order | Line-level on return order | All support line-level |