
Order Reconciliation for D2C Brands in India: Shopify, WMS and Fulfilment into One Simplified Status
Your Shopify dashboard says you did ₹1.2 crore this month. Your bank, three weeks later, sees a third less. That gap is not fraud and it is not a bug. It is the space between an order being placed and money actually surviving the trip to your customer and back. For an Indian D2C brand running most of its orders on COD, that space quietly swallows a quarter of revenue, reverses GST you have already paid, and trains Meta to go find you more of the customers who never take delivery. Order reconciliation is how you close it: pulling Shopify, your warehouse system and your courier into one agreed status, so the number you scale on is the number you actually keep.
TL;DR
Your Shopify dashboard is not your sales number. It is a record of intent. Between the order and the money there are three systems that each hold a different version of the truth: your storefront, your warehouse management system, and your shipping aggregator. Reconciliation is the process of pulling all three, resolving the conflicts with a fixed precedence rule, and collapsing roughly 60 raw courier statuses into about 12 statuses a human can act on.
Do it properly and you get four things: a real sales number, correct GST filings driven off the invoice and credit note dates in your WMS, clean conversion signals going back to Meta so the algorithm stops buying you RTO, and a contribution margin that includes the cost of every parcel that came back.
This guide covers the full build. It includes the verified Shiprocket status code tables, a status resolution algorithm, the GST rules that apply as of August 2026, and the RTO cost maths.
1. What order reconciliation actually means
Order reconciliation is the process of taking one order and answering, with evidence, five questions:
Did it physically leave the warehouse, and on what date?
Did it reach the customer, and on what date?
Did it come back, and in what condition?
What tax document was raised against it, and does a credit note need to follow?
Did the money land, and how much of it survived the fees?
Most Indian D2C brands answer question one from Shopify, question two from a courier dashboard, question four from a Tally export, and question five from a bank statement two weeks later. Those four answers are never in agreement, which is exactly why the founder ends up with three different revenue numbers by Friday.
What reconciliation is not: it is not a fulfilment status field in Shopify. Shopify marks an order "fulfilled" when you attach a tracking number. That happens at dispatch. An order can sit in "fulfilled" for eternity while the parcel is lost in a Bhiwandi hub.
2. Why your three systems disagree
Each system is honest about a different thing.
System | Knows the truth about | Is guessing about |
|---|---|---|
Shopify / storefront | Customer intent, price, discount, tax charged, payment method, refunds issued | Whether anything physically happened |
WMS | Stock allocation, invoice number and date, dispatch date, what was actually packed, what came back into the bin | Where the parcel is once the courier takes it |
Shipping aggregator | Every physical scan from pickup to delivery to RTO | The contents and commercial value of the parcel |
Here is one real order seen through all three:
Field | Shopify says | WMS says | Shiprocket says |
|---|---|---|---|
Status | Fulfilled | Dispatched, invoice INV-4471 dated 03 Aug | RTO Delivered |
Date | 01 Aug, order placed | 03 Aug, removal | 14 Aug, RTO received at hub |
Value | ₹1,899 collected? No, COD pending | ₹1,899 invoiced, GST ₹289.68 | COD ₹1,899, not collected |
Three systems, three states, one order. Without a resolution rule you will report this as revenue.
3. Pull 1: the storefront (Shopify)
Start here because it is the only place with the commercial terms of the order.
What to pull
Order header:
id,name,created_at,processed_at,cancelled_at,cancel_reason,currency,tags,note_attributesMoney:
total_price,subtotal_price,total_discounts,total_tax,total_shipping_price_set,financial_statusLine items:
sku,variant_id,quantity,price,total_discount,tax_linesCustomer and address: hashed email and phone,
shipping_addressincluding pincode, city, state, plusdefault_addresshistory for repeat detectionPayment: gateway,
payment_gateway_names, transaction records, and for COD the app or gateway tag that flags itRefunds:
refunds[]withcreated_at,refund_line_items,transactionsFulfilments:
fulfillments[]withtracking_number,tracking_company,created_at
How to pull it
Use the orders/create, orders/updated, orders/cancelled and refunds/create webhooks for the live stream, and a nightly Bulk Operations query as the backfill that repairs whatever the webhooks dropped. Never rely on webhooks alone. They fail silently, they arrive out of order, and Shopify retries can create duplicates.
The gotchas that break reconciliation
fulfillment_statusis not delivery. It flips at dispatch. Treat it as "handed over", nothing more.financial_statuson COD is misleading. COD orders usually sit atpendingand only move topaidwhen someone marks them paid, often in a bulk action weeks later, which destroys your ability to date the cash.Partial refunds and gift cards. A refund is not a return. Goodwill refunds with no goods movement must not create a stock inward or a credit note against goods returned.
Draft orders and test orders. Filter by
test: trueand exclude your own staff orders by tag, or your RTO rate will look fictional.
4. Pull 2: the WMS
This is the layer most brands skip, and it is the one that makes tax filing correct. The WMS is the only system that knows the date goods left your premises, which is the date that legally matters.
What to pull
order_referencemapped to the Shopify order nameAllocation, picklist, pack and QC events with timestamps
Invoice number and invoice date (the single most important field for GST)
Credit note number and credit note date
E-way bill number where applicable
Dispatch or removal timestamp, and the manifest it went out on
AWB and courier assigned at pack time
Actual packed weight and dimensions, per box
Batch, lot and expiry for regulated categories
Inward events: GRN on RTO receipt, QC outcome (sellable, refurb, scrap), bin put-away
Short-ship and split-shipment records
Why the WMS wins on tax dates
Under Section 31 of the CGST Act, a tax invoice for goods has to be issued before or at the time of removal of goods for supply to the recipient. Removal happens at your warehouse gate. Not when the customer clicked buy, and not when the courier scanned it into a hub. Your WMS is the system that observes removal, so the WMS invoice date is the date that goes into GSTR-1.
A related trap: Shopify itself does not raise a GST tax invoice. It produces an order confirmation and a receipt. The statutory tax invoice comes from your WMS, ERP, or a GST invoicing app. The problem appears when that invoice is triggered at order or checkout time, by an app or an ERP hook that fires on order creation, while the goods actually leave three days later. Now your invoice date and your removal date disagree, and the mismatch is invisible until an officer asks. Trigger the invoice on the dispatch event, not the order event.
The gotcha
A single Shopify order can produce two WMS invoices if the warehouse splits it. Two invoices means two AWBs, two delivery outcomes, and potentially one credit note against one of them. If your data model assumes one order equals one shipment, splits will silently corrupt every downstream number. Model at shipment grain from day one.
5. Pull 3: the shipping provider
The courier or aggregator is the only witness to physical reality after pickup. Everything about where the parcel is comes from here.
What to pull
AWB, courier name, courier company id
The full scan trail, not just the current status. You need the scan history to compute time in transit, NDR attempts, and how long an RTO sat before it reached your dock.
Pickup date, EDD, delivered date, RTO delivered date
NDR reason codes and attempt counts
Weight discrepancy records and the charged weight versus your packed weight
Freight billed per shipment, plus COD handling fee
COD remittance records with UTR references
POD (proof of delivery) availability and, where the courier supports it, QC image and QC failure reason on return pickups
Shiprocket specifically
Two mechanisms, and you want both.
Webhooks for the live stream. Set them at Settings, API, Webhooks. Shiprocket posts JSON to your endpoint on every tracking event, and the payload carries the full scan array. Note the two rules in their documentation that trip people up: your webhook URL must not contain the words shiprocket, kartrocket, sr or kr, and your endpoint must return HTTP 200 and nothing else.
Polling for the backfill. GET /v1/external/courier/track/awb/{awb} returns tracking_data with shipment_status, a shipment_track array whose object holds pickup_date, delivered_date, current_status, pod, and a shipment_track_activities array where every scan carries sr-status and sr-status-label. Run this nightly for every AWB not in a terminal state and for every AWB whose last event is more than 24 hours old.
Rate limits are documented in the response headers as X-RateLimit-Limit: 180. Build for it. Queue, do not hammer.
6. The precedence rule: courier, then WMS, then Shopify
The core rule is simple:
For anything physical, the shipping provider wins. If the courier is silent, the WMS wins. If both are silent, Shopify wins.
The courier wins because it has the only sensor network. Your WMS thinks the parcel is "dispatched" forever, because from its point of view the job ended at the gate. Shopify thinks the order is "fulfilled" forever, for the same reason. Only the courier knows the parcel came back.
But precedence is not a single number per order. It is per field. This is the part that most in-house builds get wrong.
Field | Source of truth | Why |
|---|---|---|
Physical status (in transit, delivered, RTO) | Shipping provider | Only system with scans |
Delivered date, RTO date | Shipping provider | Same |
Dispatch or removal date | WMS | Legal date of removal, courier pickup scan can lag by a day |
Invoice number and date | WMS | Tax document |
Credit note number and date | WMS or ERP | Tax document |
What was actually in the box | WMS | Pack confirmation, not the cart |
Packed weight | WMS, disputed against courier charged weight | You need both to fight discrepancy charges |
Order value, discount, tax charged | Shopify | Commercial terms live here |
Payment method and capture | Shopify plus gateway | Courier only knows COD amount to collect |
COD cash received | Courier remittance file, reconciled to bank | Courier says remitted, bank says landed |
Cancellation before dispatch | Shopify | Cancellation happens in the storefront or CS tool |
Customer identity for ad platforms | Shopify | Hashed email, phone, fbp, fbc |
Two rules that stop precedence from misbehaving
Rule A: recency first within a source, precedence only for conflicts. Do not blindly take the courier status if the courier's last scan is older than the WMS event. Resolve by event timestamp; use source precedence only when two sources describe the same moment.
Rule B: terminal states lock. Once a shipment reaches delivered, rto_received, returned or cancelled, a lower-precedence source cannot pull it backwards. A courier can move a shipment from delivered to a return leg, because that is a genuine new leg with a new AWB. Shopify cannot move it anywhere.
How the logic resolves, in plain terms. Every status update from every source is stored as its own row in an append-only ledger, one row per source, per event, never overwritten. To decide the current status of a shipment, the system looks at all its events and picks the winner in this order: the most recent event first; if two events share the same moment, the higher-priority source wins (courier, then WMS, then storefront); and on a genuine tie it never steps backwards in the lifecycle. Invalid jumps, such as delivered moving back to out-for-delivery, are flagged as anomalies rather than applied.
Keep the ledger. Never overwrite a status column in place. When your CFO asks why an order flipped from delivered to RTO on the 14th, you want to answer in ten seconds, not reconstruct it.
7. Shiprocket's raw statuses, verified from the API
This is where the mapping work actually happens, and where most guides go vague. Below is the shipment-status code table published in Shiprocket's API documentation, the scale you actually map on, reproduced in full.
Reference note: The status codes in this article are used as a reference, taken from Shiprocket's published API documentation (
apidocs.shiprocket.in). Shiprocket updates its code tables from time to time and some values are undocumented, so treat these as the working reference and always validate against the live API response for your own account before you hard-code anything.
The trap: Shiprocket has two different status scales
This catches almost everyone. Shiprocket maintains two separate numbering systems, and the same number means different things in each.
Order status codes are used by the Orders API, as the
filtervalue whenfilter_by=status. Range roughly 1 to 90.Shipment status codes are used by the Shipments API and by the tracking response
shipment_status. The Shipments API table (reproduced below) runs to 59, but the same scale in the Tracking API documentation extends further, into fulfilment-centre and hyperlocal states in the 60s to low 80s, so treat the ceiling as flexible, not fixed at 59.
The webhook payload carries both at once. In a single real update, Shiprocket sends an order-scale id of 20 labelled "In Transit" and, in the very same message, a shipment-scale id of 18 also labelled "In Transit". Two different numbers, same meaning, both correct, because they belong to two different scales. Worse, the same number flips meaning across scales: 42 on the shipment scale is "Picked Up", while 42 on the order scale is "Archived". If you write one lookup table and point both fields at it, you will produce garbage.
Practical advice: map on the shipment-scale id and the per-scan status label inside the tracking history. Those are the values that reflect physical movement, and they are what the tracking API returns.
Two more defensive notes:
The published tables have gaps, and they do not fully agree with each other. The Shipments API table below stops at 59 with holes at 27 to 37, 53 and 58. Shiprocket's Tracking API documentation, on the same shipment scale, actually fills some of those holes (code 27 is Pickup Booked there) and adds higher codes for fulfilment-centre and hyperlocal states. On the order scale, 56, 63, 77 to 79 and 84 to 86 are missing. The lesson is not to memorise a gap list but to expect codes your table does not have, and route them to
unknownwith an alert.sr-statuscan be the literal string"NA"on scans the aggregator does not normalise. The webhook sample in Shiprocket's own documentation contains several. Do not letNAcrash your parser, and do not let it overwrite a good status.
Handle both by defaulting anything unrecognised to an unknown bucket that raises an alert, never by dropping the event and never by defaulting to the previous status.
Shipment status codes (use these)
Code | Shiprocket label |
|---|---|
1 | AWB Assigned |
2 | Label Generated |
3 | Pickup Scheduled/Generated |
4 | Pickup Queued |
5 | Manifest Generated |
6 | Shipped |
7 | Delivered |
8 | Cancelled |
9 | RTO Initiated |
10 | RTO Delivered |
11 | Pending |
12 | Lost |
13 | Pickup Error |
14 | RTO Acknowledged |
15 | Pickup Rescheduled |
16 | Cancellation Requested |
17 | Out For Delivery |
18 | In Transit |
19 | Out For Pickup |
20 | Pickup Exception |
21 | Undelivered |
22 | Delayed |
23 | Partial_Delivered |
24 | Destroyed |
25 | Damaged |
26 | Fulfilled |
38 | Reached at Destination |
39 | Misrouted |
40 | RTO NDR |
41 | RTO OFD |
42 | Picked Up |
43 | Self Fulfilled |
44 | DISPOSED_OFF |
45 | CANCELLED_BEFORE_DISPATCHED |
46 | RTO_IN_TRANSIT |
47 | QC Failed |
48 | Reached Warehouse |
49 | Custom Cleared |
50 | In Flight |
51 | Handover to Courier |
52 | Shipment Booked |
54 | In Transit Overseas |
55 | Connection Aligned |
56 | Reached Overseas Warehouse |
57 | Custom Cleared Overseas |
59 | Box Packing |
The separate order-status code table runs to roughly 90 values and covers the entire return and exchange lifecycle plus fulfilment-centre states. You rarely map on it directly; when you need it, pull the current list from Shiprocket's live API documentation rather than a cached copy, since it changes.
8. The simplified status model
Roughly 60 shipment codes and 90 order codes. Nobody on your team can hold that in their head, and no customer should ever see "RTO_NDR".
Collapse in two layers.
Layer 1: the analytics status (12 states)
These are the states your finance and growth reporting runs on. Each one carries flags that decide what the rest of the business does with it.
# | Canonical status | Revenue recognised | Stock committed | Cost incurred | Terminal |
|---|---|---|---|---|---|
1 |
| No | Soft | None | No |
2 |
| No | Hard | None | No |
3 |
| No | Hard | Pick, pack, packaging | No |
4 |
| No | Out | Forward freight | No |
5 |
| No | Out | Forward freight | No |
6 |
| No | Out | Forward freight, reattempt | No |
7 |
| Yes | Out | Full forward cost | Yes* |
8 |
| No | In return | Forward plus return freight | No |
9 |
| No | Back in stock | Full RTO cost | Yes |
10 |
| Reversing | In return | Reverse pickup | No |
11 |
| Reversed | Back in stock, maybe scrap | Full return cost | Yes |
12 |
| No | Released | Usually none | Yes |
13 |
| No | Unknown | Write-off pending | Yes |
* delivered is terminal for the forward leg. A customer return opens a new leg with its own AWB, which is why returned is a separate terminal state rather than a reversal of delivered.
exception covers lost, destroyed, damaged and disposed. Keep it as one bucket with a sub-reason. These are claims, they need a human, and they should never quietly sit in in_transit for 45 days inflating your in-flight revenue.
Layer 2: the customer-facing status (7 states)
Customers do not need your analytics states. Show them:
Order placed → Packed → Shipped → Out for delivery → Delivered, plus Return in progress and Cancelled.
Map delivery_failed to "Out for delivery" with a nudge message, not to a scary "Undelivered". Map rto_in_transit to "Return in progress" and open a support thread, because at that point you still have a chance to convert it into a redelivery.
Why 12 and not 20
Every extra status is a decision someone has to make. The test for whether a status deserves to exist: does a different person do a different thing when they see it? "Reached at Destination" and "In Transit" both mean the ops team waits. Same action, so same status. "Undelivered" and "In Transit" trigger completely different actions, so they stay separate.
9. The full mapping table: Shiprocket to simplified
Mapped on the shipment status scale. This is the table you encode.
Shiprocket code | Shiprocket label | Simplified status | Notes |
|---|---|---|---|
11 | Pending |
| Not yet actioned |
52 | Shipment Booked |
| Booked, not picked |
1 | AWB Assigned |
| |
2 | Label Generated |
| |
59 | Box Packing |
| |
5 | Manifest Generated |
| Manifest is not dispatch |
3 | Pickup Scheduled/Generated |
| |
4 | Pickup Queued |
| |
19 | Out For Pickup |
| Courier en route to you |
15 | Pickup Rescheduled |
| Alert if repeated |
13 | Pickup Error |
| Ops alert. Parcel still with you |
20 | Pickup Exception |
| Ops alert |
42 | Picked Up |
| The true dispatch event |
6 | Shipped |
| |
51 | Handover to Courier |
| |
18 | In Transit |
| |
38 | Reached at Destination |
| |
48 | Reached Warehouse |
| |
50 | In Flight |
| Air leg |
54 | In Transit Overseas |
| Export |
55 | Connection Aligned |
| Export |
56 | Reached Overseas Warehouse |
| Export |
49 | Custom Cleared |
| Export or import |
57 | Custom Cleared Overseas |
| Export |
39 | Misrouted |
| Ops alert. Start an SLA timer |
22 | Delayed |
| Ops alert. Breach of EDD |
17 | Out For Delivery |
| |
21 | Undelivered |
| NDR. Act within 24 hours |
7 | Delivered |
| Revenue event |
23 | Partial_Delivered |
| Split at line level, part credit note |
26 | Fulfilled |
| |
43 | Self Fulfilled |
| Own-fleet or hyperlocal |
9 | RTO Initiated |
| Cost event starts |
46 | RTO_IN_TRANSIT |
| |
41 | RTO OFD |
| Out for delivery back to you |
40 | RTO NDR |
| Ops alert. RTO itself failing |
14 | RTO Acknowledged |
| Courier acknowledged, not received |
10 | RTO Delivered |
| Only close on WMS GRN |
47 | QC Failed |
| Return QC failed, sub-reason required |
16 | Cancellation Requested |
| Not yet cancelled |
8 | Cancelled |
| |
45 | CANCELLED_BEFORE_DISPATCHED |
| No credit note if no invoice |
12 | Lost |
| Claim. Write-off timer |
24 | Destroyed |
| Claim |
25 | Damaged |
| Claim |
44 | DISPOSED_OFF |
| Terminal write-off |
anything else | unknown |
| Alert. Never silently absorb |
If you use Shiprocket Fulfilment or hyperlocal, you will also see the higher tracking-scale codes (roughly 60 to 83): fulfilment-centre states such as FC Allocated, Picklist Generated, Packed and FC Manifest Generated map to processing, while Untraceable and RTO_LOCK belong in exception. Add the ones you actually encounter to the table rather than leaving them to fall through to unknown.
Three mapping decisions worth defending
"RTO Delivered" is not the end of an RTO. Shiprocket says code 10 when the courier delivered the parcel back to your address. It does not know whether your warehouse counted it, opened it, and found the product inside. Only close the loop when the WMS raises a GRN. The gap between courier RTO Delivered and WMS GRN is one of the most reliable places to find shrinkage. Run a report on it every week.
"Manifest Generated" is not dispatch. Plenty of brands treat manifest as the dispatch event because it is convenient. A manifest is a printed list. Parcels on it can sit for two days. Use code 42, Picked Up, or your WMS gate-out scan, whichever the precedence rule selects.
"Undelivered" is not a failure, it is a queue. Code 21 is the single highest-leverage status in the whole table. A parcel in NDR is still recoverable. It becomes an RTO only after failed reattempts. Treat delivery_failed as a work queue with a 24-hour SLA, not a status.
10. Building the pipeline: keys, grain and the status ledger
Get the grain right
Model at shipment-line grain, not order grain. The hierarchy runs four levels deep: a Shopify order contains one or more shipments (each is a WMS pack with one AWB); each shipment contains one or more lines (a SKU, its quantity, and its invoice line); and each line accumulates a trail of status events, where every event records its source, the raw status, the simplified status, and its timestamp.
One order can split into two shipments. One shipment can carry five lines. One line can be partially delivered. One RTO can be re-shipped on a second AWB. If your table is one row per order, none of that survives.
The join keys, and how they break
From | To | Key | Failure mode |
|---|---|---|---|
Shopify | WMS | Order name ( | WMS strips the |
Shopify | Shiprocket |
| This is the bare merchant order id (e.g. |
WMS | Shiprocket | AWB | Reassigned AWBs on courier switch. Keep AWB history, not just current |
Shiprocket | Shiprocket |
| Different ids. One order, many shipments |
Everything | Money | Invoice number | The only key finance will accept |
Store every id you ever see. Storage is cheap. Re-deriving a broken join six months later is not.
Idempotency and late events
Webhooks arrive out of order. A "Delivered" can land before the "Out for Delivery" that preceded it. Three defences:
Deduplicate on a natural key:
(awb, raw_status, event_ts). Hash it, unique-index it, upsert.Order by
event_ts, notingested_ts. Keep both. The gap between them is your data latency metric, and when it blows out you want to know.Validate transitions. Maintain an allowed-transition matrix.
delivered → out_for_deliveryis invalid and should be logged as an anomaly, not applied.
The exception queues that actually matter
Build these as views, and put them in front of a human every morning:
Dispatched more than 7 days ago, no delivery scan, no RTO scan
Courier says RTO Delivered, WMS has no GRN after 5 days
Delivered more than 21 days ago, COD not remitted
Delivered, but Shopify still shows unfulfilled
Shopify cancelled, but the AWB is in transit
Invoice raised, no dispatch scan within 48 hours
Charged weight more than 15% above packed weight
NDR open for more than 24 hours
Unknown status code observed
Two shipments against one order where only one has an invoice
Refund issued in Shopify with no credit note in the WMS
Credit note in the WMS with no refund and no RTO
Number 11 and number 12 are the two that will save you at assessment time.
11. Reconciliation and GST filings
Note: the following is a practitioner's summary of the rules as they stand in August 2026. It is not tax advice. Run your specific treatment past your CA before you change anything.
This is where reconciliation stops being an ops nicety and becomes a compliance requirement. Every number in GSTR-1 has to be traceable to a document, and every document has to be traceable to a physical movement.
The two dates that drive everything
Invoice date, from your WMS. Section 31 of the CGST Act requires the tax invoice for goods to be issued before or at the time of removal. That is your dispatch. Not your order date.
Credit note date, from your WMS or ERP. Section 34 governs when a credit note can be issued: where the taxable value or tax charged in the invoice exceeds what is actually payable, or where goods are returned, or where goods or services are found deficient.
An RTO is exactly this situation. Output tax became payable at removal (that is when the time of supply crystallised and the invoice was raised), but the goods came back undelivered. The credit note is the instrument that reverses that liability. Do not think of an RTO as "no sale ever happened, so no tax arose"; the liability did arise at the gate, and the credit note is what unwinds it.
The deadline that catches people
Under Section 34(2), the credit note has to be declared in a return no later than 30 November following the end of the financial year in which the original supply was made, or the date of furnishing the relevant annual return, whichever is earlier.
Miss it and the tax stays paid. Forever. On goods you never sold.
Now do the maths on an unreconciled brand. An RTO dispatched in February gets received back in March. Nobody raises a credit note because nobody has a report that says "these invoices need reversing". The financial year closes. By the following 30 November the window shuts. On 1,500 RTOs a month at an average invoice of ₹1,899 inclusive of 18% GST, the tax component is ₹289.68 per order, so roughly ₹4.3 lakh of tax per month paid on nothing. Over a year, north of ₹50 lakh.
That is the entire business case for reconciliation, in one paragraph.
A note on RTO treatment: the compliant practice is to raise the invoice at removal and then a credit note against it on RTO receipt. You will hear of brands that invoice on delivery instead, to avoid credit notes entirely; treat that as non-compliant for goods that move, because Section 31(1) requires the invoice before or at removal, and deferring it to delivery is hard to defend. Confirm your route with your CA and then stay consistent, because switching methods mid-year creates its own reconciliation problem.
Beyond the invoice-and-credit-note mechanics above, a D2C brand's GST position also touches e-invoicing, e-way bills, the Invoice Management System, TCS on marketplace sales and the periodic rate changes. Those carry real deadlines and conditions that shift year to year, so treat them as a checklist to work through with your CA rather than something to self-serve from this article.
12. Why order status is a growth problem, not a finance problem
Here is the part that changes how you spend money.
12.1 Your actual sales number
Take a brand doing 10,000 orders a month at ₹1,200 net of GST, 60% COD, spending ₹24 lakh on ads.
Apply the published Indian benchmarks. Shipway's FY25 ShipNotes report, based on shipments across its D2C base, found that less than 2% of prepaid orders come back, while roughly 26% of non-prepaid orders do.
At 60% COD, that is 5,760 COD orders and 3,840 prepaid on the 9,600 that actually ship.
Step | Orders | Running revenue |
|---|---|---|
Orders placed | 10,000 | ₹1,20,00,000 |
Less cancelled pre-dispatch (4%) | 9,600 | ₹1,15,20,000 |
Less RTO (26% of 5,760 COD + 2% of 3,840 prepaid = 1,574) | 8,026 | ₹96,31,200 |
Less post-delivery returns (6% of delivered) | 7,544 | ₹90,52,800 |
Your dashboard says ₹1.20 crore. Your bank, eventually, sees ₹90.5 lakh. The dashboard overstates by 33%.
Now restate ROAS:
Reported ROAS: ₹1,20,00,000 / ₹24,00,000 = 5.0
Net delivered ROAS: ₹90,52,800 / ₹24,00,000 = 3.77
Same spend. Same campaigns. A difference of almost a quarter in the number you are optimising against. Every scaling decision you made at 5.0 was made on a number that does not exist.
12.2 The signal you are sending Meta
This is the compounding problem, and it is the one most brands never fix.
When a customer places a COD order, your pixel fires Purchase. Meta records a conversion. Meta's optimisation then goes looking for more people who behave like that person. If that order comes back as an RTO, Meta never finds out. It keeps buying you the audience that places orders and does not take delivery.
You are paying Meta to find you your worst customers, and you are paying it with your own data.
Why you cannot just fix it after the fact: there is no negative Purchase event and no way to un-spend an optimisation signal once Meta has learned from it. Refunds, value updates and privacy-driven event deletion do not reverse the audience the algorithm already went and bought. Two timing facts make the after-the-fact fix impossible. First, Meta only deduplicates a Pixel and a Conversions API copy of the same event if it receives them within 48 hours of each other, so that window is about matching, not retraction. Second, and more decisive, Meta's attribution windows top out at 7-day click and 1-day view, and a conversion that lands more than 7 days after the click cannot be attributed back to the ad that drove it. Your RTO resolves in 12 to 20 days, well outside both windows. The fix has to be designed in before the event fires, not bolted on after.
Three things that work:
1. Move optimisation to a confirmed event. Keep firing Purchase at checkout for on-site attribution, and additionally fire a server-side custom event, call it PurchaseConfirmed, when the order actually deserves to count. For prepaid, that is at payment capture, within minutes. For COD, fire it at COD verification, the WhatsApp or IVR confirmation you do before dispatch, usually a day or two out, not at delivery. Delivery lands 12 to 20 days after the click, outside Meta's 7-day attribution window, so a delivery-fired event can never be tied back to the ad that drove it and is useless for optimisation. COD verification is the latest point that still falls inside the window. Then build a custom conversion on PurchaseConfirmed and point your campaigns at it. Note that Meta retired the standalone Offline Conversions API on 14 May 2025, and the Conversions API now handles offline events directly, so this is one integration, not two.
2. Build exclusion audiences from your reconciled data. Upload a Custom Audience of hashed phone numbers and emails for customers with two or more RTOs, and exclude it from prospecting. This is only possible if you have a reconciled customer-level RTO history, which is the whole point of the exercise.
3. Feed lookalikes from delivered, kept customers. Your seed audience should be customers whose orders reached delivered and never reached returned. Most brands seed from all purchasers, which includes the RTO cohort by definition.
(Likely: items 1 to 3 are established practitioner patterns rather than a documented Meta product for RTO suppression. The 48-hour deduplication window, the 7-day attribution ceiling and the Offline Conversions API retirement date are all from Meta's own documentation. Test the confirmed-event switch on a subset of campaigns before moving your whole account: holding the event back to COD verification reduces the volume of signal Meta receives, and a low-volume account can fall out of the learning phase. Keep the confirmation step inside a day or two so the event stays within the attribution window.)
12.3 What RTO and returns are doing to your CM2
RTO does not just remove revenue. It adds cost. You pay freight in both directions on a parcel that earned nothing.
Cost of one RTO:
Component | Amount |
|---|---|
Forward freight | ₹75 |
Return freight | ₹75 |
Packaging, consumed | ₹20 |
Pick and pack labour | ₹15 |
Inward QC and put-away | ₹20 |
Write-off, 3% of units unsellable | ₹11 |
Total per RTO | ₹216 |
Cost of one post-delivery return:
Component | Amount |
|---|---|
Forward freight | ₹75 |
Reverse pickup | ₹90 |
Packaging, consumed | ₹20 |
Pick and pack labour | ₹15 |
QC and refurbishment | ₹25 |
Write-off, 15% of units unsellable | ₹54 |
Refund processing fee | ₹24 |
Total per return | ₹303 |
On the 10,000-order month above: 1,574 RTOs and 482 returns, so ₹3.40 lakh plus ₹1.46 lakh, roughly ₹4.9 lakh of pure reverse-logistics cost that never appears next to the revenue it destroyed.
Now the CM2 comparison. Naive CM2 charges every cost against every order and books all revenue. Reconciled CM2 books only kept revenue and charges the real fulfilment cost of every dispatch, forward and reverse.
Line | Naive (unreconciled) | Reconciled |
|---|---|---|
Revenue | ₹1,20,00,000 | ₹90,52,800 |
COGS | ₹36,00,000 | ₹27,15,840 |
Forward freight | ₹7,50,000 | ₹7,20,000 |
Return freight (RTO plus returns) | ₹0 | ₹1,61,430 |
Packaging and handling | ₹3,50,000 | ₹3,36,000 |
Payment and COD fees | ₹2,46,000 | ₹1,99,325 |
Reverse QC and put-away | ₹0 | ₹43,530 |
Write-offs | ₹0 | ₹43,342 |
CM2 | ₹70,54,000 | ₹48,33,333 |
CM2 % of reported revenue | 58.8% | 40.3% |
A gap of about ₹22 lakh a month. Same business, same month, two numbers. One of them is the one you are making hiring and inventory decisions on. Note the reconciled CM2 is 40.3% of reported revenue, held against the naive 58.8% on the same base so the two are comparable; measured against the ₹90.5 lakh you actually keep, the margin is 53.4%.
(Assumption: the unit costs above are an illustrative model at plausible mid-market Indian D2C rates, not benchmarks. Substitute your own rate card. The RTO percentages are sourced from Shipway's FY25 ShipNotes report. The structure of the calculation is what matters, and it holds at any rate card.)
12.4 Reducing RTO, returns and fake addresses
Reconciled data does not just measure the problem. It tells you which lever to pull.
Attack the address before dispatch:
Score every address before you print a label. Shiprocket exposes a Sense Address Score API, an Address Verification API and an RTO Prediction API for exactly this. Fail-score addresses go into manual review, not into a manifest.
Validate the pincode against a clean master. Serviceability is not the same as deliverability.
Flag repeat offenders at checkout using your own reconciled history, matched on phone number, since Indian buyers change emails freely and phone numbers rarely.
Block obvious junk: 10 digits of the same number, single-character names, an address field shorter than 15 characters.
Attack COD, which is where the RTO lives:
Verify every COD order by WhatsApp or IVR before dispatch, with an auto-cancel on no response. This is the single highest-return intervention available to an Indian D2C brand.
Offer a prepaid discount sized against your actual RTO cost. If an RTO costs you ₹216 and your COD RTO rate is 26%, the expected cost of accepting a COD order is roughly ₹56. A ₹50 prepaid discount is free money.
Take a partial COD deposit on high-value orders.
Gate COD by pincode using your own RTO rates, not the courier's serviceability file. Shipway's city-level data ranges from 18% in Vadodara to 35% in Patna, so a national COD policy is leaving margin on the table in both directions.
Cap COD by order value. The ₹500 to ₹1,000 band showed the highest RTO rate at 28%, above both cheaper and more expensive orders, which points at impulse buying and post-purchase regret.
Attack delivery speed:
Orders attempted within 1 to 2 days recorded a 22% RTO rate. Beyond 5 days, 35%. That is a 13 point swing bought with warehouse placement and courier selection, not with discounts. If you are shipping the whole country from one Delhi warehouse, a second node in Bengaluru or Mumbai is an RTO reduction project as much as a delivery speed project.
Attack NDR:
Every delivery_failed is a save opportunity with a 24-hour clock. Route it into a queue with a named owner. Auto-trigger a WhatsApp message with a reschedule link and an address-correction link. Measure NDR-to-delivery conversion as a first-class metric.
Attack returns, which are a different problem:
Returns are product and expectation problems, not logistics problems. Reconciled data lets you rank return rate by SKU, by size, by first-time versus repeat buyer, and by acquisition channel. A single SKU with a 22% return rate against a 6% baseline is a size chart problem or a photography problem, and it is invisible in aggregate.