Order Module — Storefront
HTTP surface for the customer-side order lifecycle — payment provider discovery, place-order, list/detail, customer-initiated cancel, and the customer-side return flow…
HTTP surface for the customer-side order lifecycle — payment provider discovery, place-order, list/detail, customer-initiated cancel, and the customer-side return flow (eligibility, photo upload, request, list, detail, cancel).
Source:
api-modules/order/src/controllers/store-orders.controller.ts,store-returns.controller.ts.The module orchestrates the cart → order handoff: it consumes
CartService(cart resolution + checkout commit),PaymentRegistry(provider/method dispatch),InventoryService(reservation lifecycle), and the shipping registry. Webhooks (payment, courier) live in their own provider modules.
Conventions
Authentication
| Endpoint group | Auth |
|---|---|
GET /store/checkout/payment-providers | required (customer) |
POST /store/checkout/place-order | required (customer) |
GET /store/orders, GET /store/orders/:id, POST /store/orders/:id/cancel | required (customer) |
/store/orders/:id/returns/**, /store/returns/photos | required (customer) |
Customer order detail and return detail enforce a no-leak rule: ids that belong to a different customer return 404 Not Found, never 403.
Headers
POST /store/checkout/place-order requires:
| Header | Required | Notes |
|---|---|---|
x-cart-token | yes | Cart handle issued by the cart endpoints. Identifies the active cart even for a logged-in customer (handles the guest→customer adoption window) |
x-platform | no | WEB or APP (case-insensitive). Defaults to WEB. Used to pick a platform-specific enabled payment provider list |
GET /store/checkout/payment-providers accepts x-platform only.
Response envelope
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* optional, e.g. pagination */ }
}Error envelope
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR, PAYMENT_PROVIDER_NOT_ENABLED, PAYMENT_METHOD_INVALID |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN (payment provider not enabled, or cart not yours) |
| 404 | NOT_FOUND |
| 409 | CONFLICT, INVALID_TRANSITION, PARENT_NOT_CANCELLABLE |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Currency
All money fields (subtotal, discountTotal, shippingTotal, taxTotal, grandTotal, unitPrice, lineSubtotal, lineTotal, discountAllocated, netAmount, refundAmount, tax components) are integer subunits (paise / cents / eurocents).
Lifecycle (customer-visible)
Parent — order.status
| Status | Notes |
|---|---|
pending_payment | SDK-driven providers (Razorpay etc.); inventory reserved but not committed |
confirmed | Synchronous payment (COD/manual) at place-order, or payment webhook |
cancelled | Customer/admin cancel, or all sub-orders cancelled. Terminal for the customer; an admin can restore it to confirmed (see the admin order docs). |
Parent payment — order.payment_status
| Status | Notes |
|---|---|
pending | Initial value |
paid | Provider success, webhook, admin mark-paid, or admin mark-paid for COD cash collection |
failed | Provider reported the payment did not go through. The order stays pending_payment and can be retried — see Retry payment |
refunded | Admin bookkeeping flag |
Sub-order — order_vendor.fulfillment_status
pending → fulfilled → delivered, with cancelled reachable from pending or fulfilled. Customers don't drive these — they only observe them via vendorBreakdowns[].fulfillmentStatus.
Domain types
OrderResponse
type OrderStatus = "pending_payment" | "confirmed" | "cancelled";
type PaymentStatus = "pending" | "paid" | "failed" | "refunded";
type Platform = "APP" | "WEB" | "BOTH";
type OrderLineType = "PRODUCT" | "GIFT";
type AddressBlock = {
firstName: string;
lastName: string;
fullAddress: string;
city: string;
pincode: string;
state: string;
phone: string;
country: string;
};
type OrderResponse = {
id: string;
orderNumber: string;
customer: { id: string; name: string; email: string } | null; // The customer account that placed the order; null for legacy/guest orders.
status: OrderStatus;
paymentStatus: PaymentStatus;
paymentProvider: string; // e.g. "manual", "razorpay", "phonepe", "ccavenue"
paymentMethod: string; // e.g. "cod", "upi", "card"
platform: Platform;
shippingAddress: AddressBlock;
billingAddress: AddressBlock;
customerNote: string | null; // shopper's checkout instruction, snapshotted at placement
subtotal: number; // subunits
discountTotal: number;
shippingTotal: number;
taxTotal: number;
grandTotal: number;
vendorBreakdowns: OrderVendorResponse[];
events: OrderEventResponse[]; // tail of audit-log rows (most recent first)
/** Bootstrap data the storefront/SDK uses to complete a client-driven
* payment flow. Absent for synchronous providers like manual COD.
* `payload` is provider- and platform-specific — see below. */
pendingClientAction: {
provider: string;
payload: Record<string, unknown>;
} | null;
placedAt: string; // ISO
confirmedAt: string | null;
paidAt: string | null;
cancelledAt: string | null;
cancellationReason: string | null;
};OrderVendorResponse
type OrderVendorResponse = {
id: string;
vendorId: string;
vendorNameAtOrder: string; // snapshot
fulfillmentStatus: "pending" | "fulfilled" | "delivered" | "cancelled";
subtotal: number;
discountAllocated: number;
shippingCost: number;
taxAmount: number;
total: number;
shippingProviderId: string | null;
shippingMethod: string | null;
trackingCode: string | null;
awbNumber: string | null;
taxBreakdown: TaxComponent[]; // aggregated by tax type across this vendor's lines + shipping
shippingNetAmount: number | null;
shippingTaxBreakdown: TaxComponent[];
fulfilledAt: string | null;
deliveredAt: string | null;
cancelledAt: string | null;
cancellationReason: string | null;
lines: OrderLineResponse[];
};OrderLineResponse
type OrderLineResponse = {
id: string;
vendorId: string;
variantId: string | null;
productId: string | null;
productSlug: string | null; // CURRENT store slug, resolved live for `/product/{slug}` links; null if not store-visible
sku: string;
productNameAtOrder: string; // snapshot
variantNameAtOrder: string | null;
imageAtOrder: string | null;
hsnCodeAtOrder: string | null; // GST classification snapshot from variant
type: OrderLineType;
quantity: number;
unitPrice: number;
lineSubtotal: number; // tax-inclusive amount displayed
discountAllocated: number;
lineTotal: number;
netAmount: number | null; // pre-tax portion
taxBreakdown: TaxComponent[];
};OrderEventResponse
type OrderEventResponse = {
id: string;
orderVendorId: string | null;
eventType: string;
actorType: "user" | "vendor" | "admin" | "system" | "webhook";
actorId: string | null;
actor: { id: string; name: string; email: string } | null; // Always null on the store surface (operator-only; staff PII not exposed to customers).
source: string;
changes: Record<string, unknown>;
metadata: Record<string, unknown>;
createdAt: string;
};ReturnResponse
type ReturnResponse = {
id: string;
returnNumber: string;
orderId: string;
orderVendorId: string;
customerId: string | null;
vendorId: string;
type: string;
status: string; // e.g. requested / approved / picked_up / received / qc_passed / qc_failed / refunded / rejected / cancelled
reasonCode: string | null;
reasonNotes: string | null;
refundAmount: number; // subunits
refundedAmount: number;
externalRefundReference: string | null;
shippingProvider: string | null;
awbNumber: string | null;
trackingCode: string | null;
rejectionReason: string | null;
qcFailureReason: string | null;
requestedAt: string;
approvedAt: string | null;
rejectedAt: string | null;
pickedUpAt: string | null;
receivedAt: string | null;
qcPassedAt: string | null;
qcFailedAt: string | null;
refundedAt: string | null;
cancelledAt: string | null;
lines: ReturnLineResponse[];
photos: ReturnPhotoResponse[];
};
type ReturnLineResponse = {
id: string;
orderLineId: string;
variantId: string | null;
quantity: number;
unitPrice: number;
taxPortion: number;
lineRefundAmount: number;
reasonCode: string | null;
reasonNotes: string | null;
restocked: boolean;
};Checkout
GET /store/checkout/payment-providers — Payment options for this platform
Returns the payment rows to render, for the caller's platform. Defaults to WEB when x-platform is missing.
options is render-ready: the operator's checkout-appearance settings (store.checkout_payment) merged over each gateway's own defaults, ordered as configured, with exactly one entry flagged isDefault. Clients should render these rows and send the chosen one's providerId + method to place-order — a storefront holding its own gateway labels or logos will silently fail to show a newly enabled gateway.
providers is the same enabled set before appearance is applied, kept for clients that dispatch on provider id.
Headers
| Header | Notes |
|---|---|
x-platform | WEB or APP |
Response 200
{
"data": {
"providers": [
{ "id": "manual", "methods": ["cod", "bank-transfer"] },
{ "id": "phonepe", "methods": ["phonepe"] }
],
"options": [
{
"key": "phonepe",
"providerId": "phonepe",
"method": "phonepe",
"label": "PhonePe",
"description": "UPI, cards, net banking and wallets via PhonePe.",
"logoUrl": "https://cdn.example.com/settings/phonepe.png",
"badge": "Fastest",
"isDefault": true
},
{
"key": "manual.cod",
"providerId": "manual",
"method": "cod",
"label": "Cash on Delivery",
"description": "Pay with cash when your order arrives.",
"logoUrl": null,
"badge": null,
"isDefault": false
}
]
},
"message": "Success",
"statusCode": 200
}| Field | Notes |
|---|---|
key | Stable option key. A single-method provider whose method equals its id collapses to the bare id (phonepe); multi-method providers qualify each one (manual.cod). |
label / description | Operator override from provider_display, else the provider's displayName / checkoutDescription, else a prettified key. |
logoUrl | Always absolute. The operator stores either a storage key or a pasted URL; the API resolves both, and it is null when unset. |
badge | Short pill copy, e.g. "Fastest". null when unset. |
isDefault | Preselect this row. The operator sets a primary per platform (default_option.web / .app), since the app and the web storefront rarely want the same one. A platform left empty, or pointed at an option not enabled there, falls back to its own first option — so no platform is ever left with nothing selected. |
Ordering. provider_display row order is the checkout order. Enabled options with no row are appended after the configured ones in registry order rather than dropped, so switching a gateway on is enough to make it appear.
Exclusions. Providers flagged hiddenAtCheckout never appear (razorpay-magic runs its own cart-side flow). Anything the platform can't serve is filtered out too, so an APP call omits WEB-only gateways.
Which gateways are available at all is set in admin — see Payment Gateways.
POST /store/checkout/place-order — Convert active cart into an order
Resolves the cart via x-cart-token (and the session's customerId), validates the chosen provider+method against the platform's enabled list, then drives OrderPlacementService.createFromCart. For client-driven providers (Razorpay, PhonePe, CCAvenue) the response carries pendingClientAction with the bootstrap data needed to complete payment. For synchronous providers (COD, manual) the order is confirmed and paymentStatus may already be paid on return.
The pendingClientAction.payload shape depends on the provider and on x-platform, because a mobile SDK needs different data than a browser:
// provider "razorpay" — Checkout SDK
{ "razorpayOrderId": "order_…", "keyId": "rzp_…", "amount": 125000, "currency": "INR", "prefill": { } }
// provider "phonepe", x-platform: WEB — hosted redirect
{ "phonepeOrderId": "OMO…", "redirectUrl": "https://…", "expireAt": 1703756259307, "amount": 125000 }
// provider "phonepe", x-platform: APP — mobile SDK
{ "phonepeOrderId": "OMO…", "token": "…", "merchantId": "…",
"environment": "SANDBOX", "flowId": "…", "expireAt": 1703756259307, "amount": 125000 }
// provider "ccavenue", x-platform: WEB — hosted redirect
{ "provider": "ccavenue", "redirectUrl": "https://api.example.com/store/orders/{id}/ccavenue/redirect",
"orderId": "ORD-2026-00000123", "amount": 125000 }
// provider "ccavenue", x-platform: APP — Flutter SDK (CCAvenueOrder)
{ "provider": "ccavenue", "encRequest": "0a744ab8…", "accessCode": "AVNU…",
"paymentEnvironment": "uat", "encryptionMode": "aes128",
"appColor": "#1F46BD", "fontColor": "#FFFFFF",
"orderId": "ORD-2026-00000123", "amount": 125000 }Redirect gateways are detected by the presence of redirectUrl, not by provider id — a client that navigates to it handles PhonePe and CCAvenue alike, and any future hosted gateway without a change.
CCAvenue on WEB is worth a note: its billing page only accepts a form POST, so redirectUrl points at an endpoint on this API that renders a self-submitting form rather than at CCAvenue directly. Clients never build that POST themselves. On APP the payload is the exact CCAvenueOrder constructor shape for ccavenue_india_sdk_flutter, carrying the same encRequest the web page posts.
For both PhonePe and CCAvenue the client must follow up with POST /store/orders/:id/{provider}/verify — neither gateway's redirect or SDK callback reports whether the payment actually succeeded, so the server asks the gateway itself. See payment-phonepe.md and payment-ccavenue.md.
Headers
| Header | Required | Notes |
|---|---|---|
x-cart-token | yes | Cart handle |
x-platform | no | WEB / APP, default WEB |
Body
{
"paymentProvider": "razorpay", // trimmed, min 1
"paymentMethod": "upi", // trimmed, min 1
"billingAddress": { // optional — when omitted, billing copies shipping
"firstName": "Ada",
"lastName": "Lovelace",
"fullAddress": "221B Baker Street",
"city": "London",
"pincode": "110001",
"state": "Delhi",
"phone": "+919876543210",
"country": "IN"
},
"customerNote": "Leave with the security desk", // optional, trimmed, max 500 chars
"expectedTotal": 125000 // optional, integer subunits — the grand total the shopper agreed to
}billingAddress field constraints reuse the address-book validators (Indian pincode regex /^[1-9]\d{5}$/, phone E.164 /^\+?[1-9]\d{6,14}$/).
customerNote is stored verbatim on the order and never edited afterwards. It is returned on the store, admin, and vendor order reads so the fulfilling side sees the instruction.
expectedTotal guards against a silent repricing. Placement re-prices the cart from live data, so a price, discount, or shipping change between the customer seeing the total and pressing pay would otherwise be charged without telling them. Send the cartTotals.total the customer was shown (the value from prepare-checkout); if the freshly-priced cart disagrees, the call fails with 409 CART_TOTAL_CHANGED. The error's details.removedCoupons names any coupon that lapsed during re-pricing, which is the usual reason the total moved, so the shopper can be told why rather than only that. Re-fetch the cart, show the new total, and re-submit with the new expectedTotal.
No order is created by a rejected attempt: no order, sub-orders, or lines, no payment row, no coupon usage, and the cart stays placeable (it is not marked converted). The check does, however, run after the same cart-stage checkout preparation as POST /store/cart/prepare-checkout, so that step's effects still land:
- inventory is reserved for the cart's product lines, holding stock for
admin.payment.checkout_hold_minutes(default 15). The reservation is idempotent per cart version, so repeated rejected attempts against an unchanged cart re-use the same hold rather than stacking new ones. - a
cart.checkout.preparedanalytics row is written, and where the Klaviyo integration is enabled a "Started Checkout" event is enqueued — once per attempt. A shopper who retries through a price change therefore produces one of each per try, which can feed an abandoned-checkout flow for a checkout that never completed.
Omitting expectedTotal opts out of the guard entirely and restores the silent-repricing behaviour. It is optional only so existing mobile clients keep working, and is expected to become required — new clients should always send it.
Response 201 — OrderResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | BAD_REQUEST | x-cart-token header missing |
| 400 | PAYMENT_PROVIDER_NOT_ENABLED / PAYMENT_METHOD_INVALID | Provider/method rejected by registry |
| 400 | PINCODE_NOT_SERVICEABLE | Shipping address is on the delivery deny-list in block mode. message is the operator's own copy; details.pincode carries the pincode. See Serviceability |
| 403 | FORBIDDEN | Cart belongs to another customer |
| 404 | NOT_FOUND | Cart cannot be resolved |
| 409 | CONFLICT | Cart is no longer in an orderable status (already converted) |
| 409 | CART_EMPTY, CART_NO_PRODUCT_LINES, INSUFFICIENT_INVENTORY | Cart no longer placeable |
| 409 | CART_LINE_PRICE_UNAVAILABLE | A line has no determinable price; details.variantIds lists the offenders |
| 409 | CART_TOTAL_CHANGED | Re-pricing the cart produced a total other than the submitted expectedTotal. details.expectedTotal and details.currentTotal carry both figures (subunits), and details.removedCoupons lists the { code, reason } of any coupon dropped while re-pricing. No order is created; the cart-stage reservation and checkout-prepared events described above still happen |
Side effects — emits order.placed; for synchronous-paid providers also order.paid. The cart transitions to converted.
POST /store/checkout/buy-now/place-order — Convert a Buy Now cart into an order
Same body, response, side effects, and errors as POST /store/checkout/place-order — including the expectedTotal guard and its 409 CART_TOTAL_CHANGED. The only difference is cart resolution: x-cart-token must be a buy-now cart handle from POST /store/cart/buy-now (see cart.md) — it's resolved strictly by token and ownership, never the caller's regular active cart. Returns 404 (NOT_FOUND) if the token doesn't resolve to a buy_now cart owned by the caller.
Orders
GET /store/orders — Paginated list of my orders
Most recent first.
Query
| Name | Type | Default | Notes |
|---|---|---|---|
page | int | 1 | >= 1 |
limit | int | (module default) | 1..MAX_ORDER_PAGE_SIZE |
status | OrderStatus? | — | Filter by pending_payment / confirmed / cancelled |
startDateTime | ISO-8601? | — | Inclusive lower bound on order placed-at |
endDateTime | ISO-8601? | — | Inclusive upper bound on order placed-at; must be >= startDateTime |
Response 200 — paginated OrderResponse[].
GET /store/orders/:id — Order detail
Response 200 — full OrderResponse with embedded vendorBreakdowns and recent events.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Order does not exist or belongs to another customer |
POST /store/orders/:id/cancel — Cancel my order
Allowed only when no sub-order has yet been fulfilled or delivered. The cancel cascades to all sub-orders, releases their reservations, and emits order.cancelled. Nothing has shipped at this point, so a bag already paid for is restocked automatically — its committed stock goes back on hand.
Body — optional in full; the body may be omitted.
{ "reason": "Changed my mind" } // optional, 1..500 charsResponse 200 — cancelled OrderResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Order not yours / does not exist |
| 409 | PARENT_NOT_CANCELLABLE | At least one sub-order is past pending |
| 409 | SUB_ORDER_CHANGED_CONCURRENTLY | A sub-order advanced (e.g. to fulfilled or delivered) between the eligibility check and the write. Nothing was cancelled — re-read the order and retry. |
POST /store/orders/:id/payment/retry — Retry payment
Starts a fresh payment attempt on an order that is still awaiting payment — a declined card, or a checkout the shopper abandoned mid-gateway. The order keeps its number, addresses, coupons and pricing; only the payment session is new.
This is also the only way to recover a lost pendingClientAction: it is never persisted, so GET /store/orders/:id always returns it null. A client that lost the place-order response calls this to get a usable one back.
Works for guest orders too — a guest holds an anonymous session, so the same ownership check applies.
Headers
| Header | Required | Notes |
|---|---|---|
x-platform | no | WEB or APP. Defaults to WEB. Decides which providers are eligible. |
Body — optional in full: send no body at all (or {}) to re-pay with the provider and method the order was placed with.
{
"paymentProvider": "phonepe", // switch gateway (e.g. card declined → UPI)
"paymentMethod": "default" // defaults to the provider's first method
}Switching is allowed between enabled online providers. Offline methods (cash on delivery, bank transfer) are rejected — they change how the order settles, not just how it is paid.
Response 200 — OrderResponse carrying a fresh pendingClientAction for the new attempt.
Before opening a session the API asks the gateway about the current attempt. If it reports the order was already paid — a webhook that never arrived — the response is the paid order, and no second charge is made.
Bounds — admin.payment.retry_window_hours (default 240h / 10 days from placedAt, 0 disables retry) and admin.payment.max_payment_attempts (default 3, counting the attempt at placement). A short throttle also rejects a second attempt started within ~20s of the last.
Stock — an unpaid order only holds its inventory for admin.payment.reservation_hold_hours (default 24h), which is much shorter than the retry window. Retrying after that re-acquires the stock first: if any item has sold out in the meantime the call fails with 409 ORDER_ITEMS_UNAVAILABLE and nothing is charged.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Order not yours / does not exist |
| 400 | PAYMENT_METHOD_NOT_RETRYABLE | Target provider settles offline |
| 400 | PAYMENT_GATEWAY_ERROR | Gateway refused to open a session. The order stays payable |
| 403 | — | Provider not enabled for this platform |
| 409 | ORDER_ALREADY_PAID | Nothing left to pay |
| 409 | ORDER_CANCELLED | Retry window closed and the order was cancelled |
| 409 | ORDER_NOT_AWAITING_PAYMENT | Order is not in pending_payment |
| 409 | PAYMENT_RETRY_WINDOW_EXPIRED | Past the window, or retry is disabled |
| 409 | PAYMENT_RETRY_LIMIT_REACHED | All attempts used |
| 409 | PAYMENT_RETRY_TOO_SOON | Another attempt was just started |
| 409 | ORDER_ITEMS_UNAVAILABLE | Stock lapsed and an item has since sold out. Nothing charged |
| 503 | PAYMENT_GATEWAY_UNAVAILABLE | Could not confirm current status with the gateway. No attempt consumed — safe to call again |
Returns
Base path: /store/orders/:id/returns (with the photo-upload helper at /store/returns/photos).
GET /store/orders/:id/returns/eligibility — Per-sub-order eligibility
For each sub-order of the given order, returns whether it's currently returnable, the window expiry, the eligible reason codes, and the vendor's return policy text. Use to gate the "Request return" CTA.
Response 200
{
"data": {
"vendors": [
{
"orderVendorId": "01J9...",
"vendorId": "01J9...",
"returnable": true,
"reason": null,
"windowExpiresAt": "2026-05-20T00:00:00.000Z",
"eligibleReasons": ["DAMAGED", "WRONG_ITEM", "NOT_AS_DESCRIBED"],
"policyText": "Returns within 7 days of delivery..."
}
]
}
}When returnable is false, reason carries a stable code (e.g. WINDOW_EXPIRED, ALREADY_RETURNED, NOT_DELIVERED) and windowExpiresAt may still be populated.
POST /store/returns/photos — Presigned upload URL for return evidence
Returns a presigned PUT URL the client uses to upload evidence (one photo per call). Unused keys age out via S3 lifecycle. Pass the returned storageKey in photoKeys[] of the create-return body.
Body
{
"contentType": "image/jpeg",
"fileSizeBytes": 524288 // max 20 MiB (20 * 1024 * 1024)
}Response 200
{
"data": {
"storageKey": "returns/2026-05/abc.jpg",
"uploadUrl": "https://s3.../signed-put-url",
"expiresAt": "2026-05-13T11:45:00.000Z"
}
}POST /store/orders/:id/returns — Create a return request
Open a return against one sub-order. Service enforces:
- caller owns the order;
orderVendorIdbelongs to that order;- per-line
quantity <= delivered quantity; - return is within the vendor's window and reason is in
eligibleReasons.
Body
{
"orderVendorId": "01J9...", // sub-order being returned
"reasonCode": "DAMAGED", // 1..64 chars
"reasonNotes": "Box arrived crushed", // optional, max 2000 chars
"lines": [
{
"orderLineId": "01J9...",
"quantity": 1, // integer >= 1
"reasonCode": "DAMAGED", // optional per-line override
"reasonNotes": "Top half dented" // optional, max 2000 chars
}
],
"photoKeys": ["returns/2026-05/abc.jpg"] // optional, max 20 keys, from POST /store/returns/photos
}lines must be 1..100 entries. photoKeys must be 0..20 keys, each 1..500 chars.
Response 201 — ReturnResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod |
| 404 | NOT_FOUND | Order or sub-order not visible to caller |
| 409 | CONFLICT | Sub-order not currently returnable, or per-line quantity exceeds delivered |
GET /store/orders/:id/returns — List returns on an order
Query
| Name | Type | Default | Notes |
|---|---|---|---|
page | int | 1 | >= 1 |
limit | int | (module default) | 1..MAX_ORDER_PAGE_SIZE |
status | string? | — | 1..32 chars; filter by return lifecycle status |
Response 200 — paginated ReturnResponse[].
GET /store/orders/:id/returns/:returnId — Return detail
Response 200 — ReturnResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Return not yours / does not exist |
POST /store/orders/:id/returns/:returnId/cancel — Withdraw a return
Allowed only before the courier confirms pickup (i.e. while the return is still in the customer's hands). Once pickedUpAt is stamped the customer can't withdraw — vendor/admin paths handle reversals after that.
Response 200 — cancelled ReturnResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Return not yours / does not exist |
| 409 | CONFLICT | Already past requested/approved (courier picked up) |
Related modules
cart—prepare-checkoutruns before place-order to reserve inventory. Seecart.md.payment-razorpay/payment-phonepe/payment-ccavenue/payment-manual— concrete providers behindpaymentProvider/paymentMethod. Seepayment-razorpay.md,payment-phonepe.mdandpayment-ccavenue.mdfor their storefront verify endpoints.shipping— vendor-side shipping provider assignment; customer-side tracking is inshipping.md.storage— backs the presigned upload for return photos.customer—billingAddressshape mirrors the address-book validators. Seecustomer.md.
Notifications Module — Storefront
HTTP surface for the customer app: register/unregister FCM device tokens, the in-app notification feed (bell/inbox with read/seen state, SSE live stream, images), the per-user notification preference matrix, and the public token-gated marketing-email unsubscribe. The in-app channel mirrors push.
Payment CCAvenue Module — Storefront
The customer-side verify endpoint called after CCAvenue returns the shopper, from web and the Flutter SDK alike. The body is optional — the server reads CCAvenue's Status API rather than trusting the client's callback.