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:

  1. Did it physically leave the warehouse, and on what date?

  2. Did it reach the customer, and on what date?

  3. Did it come back, and in what condition?

  4. What tax document was raised against it, and does a credit note need to follow?

  5. 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: idnamecreated_atprocessed_atcancelled_atcancel_reasoncurrencytagsnote_attributes

  • Money: total_pricesubtotal_pricetotal_discountstotal_taxtotal_shipping_price_setfinancial_status

  • Line items: skuvariant_idquantitypricetotal_discounttax_lines

  • Customer and address: hashed email and phone, shipping_address including pincode, city, state, plus default_address history for repeat detection

  • Payment: gateway, payment_gateway_names, transaction records, and for COD the app or gateway tag that flags it

  • Refunds: refunds[] with created_atrefund_line_itemstransactions

  • Fulfilments: fulfillments[] with tracking_numbertracking_companycreated_at

How to pull it

Use the orders/createorders/updatedorders/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_status is not delivery. It flips at dispatch. Treat it as "handed over", nothing more.

  • financial_status on COD is misleading. COD orders usually sit at pending and only move to paid when 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: true and 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_reference mapped to the Shopify order name

  • Allocation, 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 shiprocketkartrocketsr 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_datedelivered_datecurrent_statuspod, 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 deliveredrto_receivedreturned 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 filter value when filter_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 unknown with an alert.

  • sr-status can be the literal string "NA" on scans the aggregator does not normalise. The webhook sample in Shiprocket's own documentation contains several. Do not let NA crash 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

pending

No

Soft

None

No

2

confirmed

No

Hard

None

No

3

processing

No

Hard

Pick, pack, packaging

No

4

dispatched

No

Out

Forward freight

No

5

out_for_delivery

No

Out

Forward freight

No

6

delivery_failed

No

Out

Forward freight, reattempt

No

7

delivered

Yes

Out

Full forward cost

Yes*

8

rto_in_transit

No

In return

Forward plus return freight

No

9

rto_received

No

Back in stock

Full RTO cost

Yes

10

return_in_transit

Reversing

In return

Reverse pickup

No

11

returned

Reversed

Back in stock, maybe scrap

Full return cost

Yes

12

cancelled

No

Released

Usually none

Yes

13

exception

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

pending

Not yet actioned

52

Shipment Booked

confirmed

Booked, not picked

1

AWB Assigned

processing


2

Label Generated

processing


59

Box Packing

processing


5

Manifest Generated

processing

Manifest is not dispatch

3

Pickup Scheduled/Generated

processing


4

Pickup Queued

processing


19

Out For Pickup

processing

Courier en route to you

15

Pickup Rescheduled

processing

Alert if repeated

13

Pickup Error

processing

Ops alert. Parcel still with you

20

Pickup Exception

processing

Ops alert

42

Picked Up

dispatched

The true dispatch event

6

Shipped

dispatched


51

Handover to Courier

dispatched


18

In Transit

dispatched


38

Reached at Destination

dispatched


48

Reached Warehouse

dispatched


50

In Flight

dispatched

Air leg

54

In Transit Overseas

dispatched

Export

55

Connection Aligned

dispatched

Export

56

Reached Overseas Warehouse

dispatched

Export

49

Custom Cleared

dispatched

Export or import

57

Custom Cleared Overseas

dispatched

Export

39

Misrouted

dispatched

Ops alert. Start an SLA timer

22

Delayed

dispatched

Ops alert. Breach of EDD

17

Out For Delivery

out_for_delivery


21

Undelivered

delivery_failed

NDR. Act within 24 hours

7

Delivered

delivered

Revenue event

23

Partial_Delivered

delivered

Split at line level, part credit note

26

Fulfilled

delivered


43

Self Fulfilled

delivered

Own-fleet or hyperlocal

9

RTO Initiated

rto_in_transit

Cost event starts

46

RTO_IN_TRANSIT

rto_in_transit


41

RTO OFD

rto_in_transit

Out for delivery back to you

40

RTO NDR

rto_in_transit

Ops alert. RTO itself failing

14

RTO Acknowledged

rto_in_transit

Courier acknowledged, not received

10

RTO Delivered

rto_received

Only close on WMS GRN

47

QC Failed

returned

Return QC failed, sub-reason required

16

Cancellation Requested

cancelled

Not yet cancelled

8

Cancelled

cancelled


45

CANCELLED_BEFORE_DISPATCHED

cancelled

No credit note if no invoice

12

Lost

exception

Claim. Write-off timer

24

Destroyed

exception

Claim

25

Damaged

exception

Claim

44

DISPOSED_OFF

exception

Terminal write-off

anything else

unknown

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 (#1001)

WMS strips the #, or prefixes a channel code

Shopify

Shiprocket

channel_order_id

This is the bare merchant order id (e.g. 1873081902), so it matches your Shopify order id directly. But the webhook reports a different field, order_id, as a composite channelId_orderId like 1373900_150876814. Join on channel_order_id; if you key off the webhook order_id, split on the underscore first, do not join raw

WMS

Shiprocket

AWB

Reassigned AWBs on courier switch. Keep AWB history, not just current

Shiprocket

Shiprocket

sr_order_id vs shipment_id

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:

  1. Deduplicate on a natural key(awb, raw_status, event_ts). Hash it, unique-index it, upsert.

  2. Order by event_ts, not ingested_ts. Keep both. The gap between them is your data latency metric, and when it blows out you want to know.

  3. Validate transitions. Maintain an allowed-transition matrix. delivered → out_for_delivery is 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:

  1. Dispatched more than 7 days ago, no delivery scan, no RTO scan

  2. Courier says RTO Delivered, WMS has no GRN after 5 days

  3. Delivered more than 21 days ago, COD not remitted

  4. Delivered, but Shopify still shows unfulfilled

  5. Shopify cancelled, but the AWB is in transit

  6. Invoice raised, no dispatch scan within 48 hours

  7. Charged weight more than 15% above packed weight

  8. NDR open for more than 24 hours

  9. Unknown status code observed

  10. Two shipments against one order where only one has an invoice

  11. Refund issued in Shopify with no credit note in the WMS

  12. 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.