Orders
Returns
How a Return moves from request to refund, the Return object, and the customer, merchant and accounting endpoints.
This is the reference for the mobile apps, the web client and accounting. The generated schema for every endpoint below is in the API reference, under Customers · Returns, Merchants · Returns and Platform · Accounting.
All /api/v1 conventions apply (see Getting started):
?lang=ar|enlocalizes messages and labels.- Money is decimal SAR.
- Errors come back as
{"status": "error", "message": "..."}. - Validation errors are Laravel's
422witherrors.
Every existing endpoint keeps its shape. The only changes to existing responses are new keys, listed under "Changes to existing endpoints" at the end.
How a Return moves#
A Return first collects what the store has to say, then waits for a Tote admin:
stateDiagram-v2
state "App Return (customer)" as app
state "Counter Return (store)" as counter
app --> awaiting_drop_off: goods coming back
app --> awaiting_store: no goods coming back
counter --> awaiting_customer
awaiting_drop_off --> awaiting_store: store records drop-off
awaiting_store --> awaiting_approval: store answers, or its window lapses
awaiting_customer --> awaiting_approval: customer confirms
awaiting_drop_off --> expired: drop-off deadline
awaiting_customer --> expired: confirmation window
Before any goods are dropped off, the customer can also withdraw the Return (withdrawn); declining a Counter Return withdraws it too.
From awaiting_approval the decision is Tote's:
stateDiagram-v2
awaiting_approval --> refunding: admin approves
awaiting_approval --> rejected: admin rejects
refunding --> refunded
refunding --> refund_failed
refund_failed --> refunding: admin retries
-
The store's answer is advice. A Tote admin approves every refund, possibly for fewer units, and records the fault party. That decides what the store is charged:
Fault party Ledger entries Store bears Tax documents customer return_reversal(the value of the returned units) andcommission_credit(Tote's commission and its VAT on them, given back)its own share a credit note to the customer, in the store's name merchant or driver the same, plus return_commission_charge: a store fault fee equal to that commission and VATthe whole refund the same credit note platform none: Tote pays it as a platform compensation, and the store's sale stands nothing none A return whose goods came back to the store cannot be the platform's fault. A refund outside a Return follows the same table, with
refund_reversalandstore_fault_feeas its entries; a refund the payment gateway reports on its own is treated as the customer's fault until a Tote admin confirms it, and its credit note waits for that confirmation. -
Refunds always go to the original card, through Moyasar. The store never pays cash for a Tote order.
-
Timing. The window is per store:
stores.return_window_days, else the platform default (7), and never below the platform floor (2). The other deadlines are platform settings:- drop-off: 7 days
- store response: 48 h after drop-off, or after opening when no goods come back
- customer confirmation of a Counter Return: 48 h
A Return the store leaves unanswered goes to the admin with
no_store_answer: true.
Statuses#
awaiting_drop_off, awaiting_store, awaiting_customer, awaiting_approval, refunding, refund_failed, refunded, rejected, withdrawn, expired.
The customer never sees refund_failed. The customer payload reports it as refunding, because an admin retries it.
Reasons#
reason |
Goods (goods_returned) |
Admin's default fault |
|---|---|---|
defective |
always returned | merchant |
damaged_on_arrival |
per line | merchant |
wrong_item |
per line | merchant |
missing_item |
never returned | merchant |
expired_or_spoiled |
per line (defaults to not returned) | merchant |
changed_mind |
always returned | customer |
other |
per line; description required |
none |
reasons in the returnable and lookup responses carry the same rules, so clients can build the form without hard-coding them:
{ "value": "defective", "label": "Defective / not working", "goods_returned": true, "requires_description": false }
goods_returned there is true (forced back), false (never back) or null (the customer chooses per line).
Photos#
Photos are sent as multipart photos[]: images, at most 5 per party, 8 MB each. They are stored privately and returned only as signed URLs that stop working after 30 minutes, so re-fetch the Return rather than caching a URL.
| When | Who | Required |
|---|---|---|
| App Return with any line not coming back | customer | ≥1, except missing_item |
| Store accepts goods that came back | store | ≥1 (unless it already attached one) |
| Counter Return | store | ≥1 |
| Store rejects | store | reason text; photos optional |
The Return object#
Customer shape (return / returns.data[] in the customer endpoints):
{
"id": 31,
"reference": "RT-1042-1",
"order_id": 1042,
"order_number": "#1042",
"store": { "id": 7, "name": { "en": "Al Noor Market", "ar": "أسواق النور" } },
"origin": "app",
"reason": "defective",
"reason_label": "Defective / not working",
"description": "Does not heat",
"status": "awaiting_drop_off",
"status_label": "Awaiting drop-off",
"lines": [
{
"id": 55,
"order_item_id": 301,
"product_id": 88,
"product_name": { "en": "Electric kettle", "ar": "غلاية كهربائية" },
"product_image": "https://…/products/kettle.jpg",
"unit_price": 120,
"requested_quantity": 1,
"approved_quantity": null,
"goods_returned": true
}
],
"requested_amount": 120,
"refund_amount": null,
"store_response": null,
"store_response_reason": null,
"decision_reason": null,
"proofs": { "customer": ["https://…signed…"], "store": [] },
"dropped_off_at": null,
"decided_at": null,
"refunded_at": null,
"created_at": "2026-09-28T10:02:11+00:00",
"updated_at": "2026-09-28T10:02:11+00:00",
"next_step": { "action": "bring_goods", "due_at": "2026-10-05T10:02:11+00:00" },
"can_withdraw": true,
"can_confirm": false
}
product_name/product_imageare the product as it was sold, not as it is now.approved_quantityisnulluntil the admin decides.0means that line was turned down.refund_amountis set on approval.next_step.actionis one ofbring_goods,wait_for_store,confirm,wait_for_review,wait_for_refund, ornullfor a closed Return.- The customer shape never names store staff and never carries the fault party.
The merchant shape (the merchant endpoints) has everything above except next_step, can_withdraw and can_confirm, plus:
{
"customer": { "id": 12, "name": "Sara", "phone": "0550000001" },
"past_window": false,
"no_store_answer": false,
"drop_off_due_at": "…",
"store_response_due_at": "…",
"customer_confirmation_due_at": null,
"dropped_off_by": "Khalid",
"store_responded_by": "Khalid",
"store_responded_at": "…",
"fault_party": "merchant",
"fault_party_label": "Merchant",
"can_record_drop_off": false,
"can_respond": true
}
fault_party stays null until the admin decides. status is the real status, refund_failed included.
Customer endpoints#
Base: /api/v1/customers/auth, guard auth:customer. Guest accounts get 403. A customer who does not own the order or Return gets 403.
GET orders/{order}/returnable#
What can still be returned.
{
"status": "success",
"data": {
"order_id": 1042,
"is_returnable": true,
"window_days": 7,
"window_closes_at": "2026-10-04T18:00:00+00:00",
"window_open": true,
"return_policy": { "en": "…", "ar": "…" },
"reasons": [ { "value": "defective", "label": "…", "goods_returned": true, "requires_description": false } ],
"items": [
{ "order_item_id": 301, "product_id": 88, "product_name": { "en": "…", "ar": "…" }, "product_image": "…",
"unit_price": 120, "quantity": 1, "returnable_quantity": 1 }
]
}
}
is_returnable is false unless the order is delivered and paid. returnable_quantity is the units ordered, minus units that are refunded, rejected, or on an open Return.
POST orders/{order}/returns — multipart, throttled 10/min#
Opens an App Return.
| Field | Rule |
|---|---|
reason |
required, one of the reasons |
description |
optional, ≤1000; required for other |
lines[i][order_item_id] |
required, an item of this order |
lines[i][quantity] |
required, 1..returnable |
lines[i][goods_returned] |
optional boolean; ignored when the reason fixes it |
photos[] |
see Photos |
The response is 201 with {status, message, return}. It returns 422 with a translated message when:
- the window closed
- a quantity is above what is returnable
- an item is not on the order
- a photo is missing
- the order is not returnable
GET returns · GET returns/{return}#
API reference: list · one Return
The customer's Returns, newest first, paginated as returns. One Return comes back as return.
POST returns/{return}/withdraw#
Allowed before drop-off, or while a Return with no goods to bring waits for the store. Otherwise 422.
POST returns/{return}/confirm · POST returns/{return}/decline#
API reference: confirm · decline
Confirm or decline a Counter Return (status = awaiting_customer). Confirming sends it to Tote for approval. Declining withdraws it.
Merchant endpoints#
Base: /api/v1/merchants/auth/stores/{store}, guard auth:merchant. The store's owner and every staff member (owner, manager, employee) have access. Everyone else gets 403. A Return from another store gets 404. Each step records which staff member acted.
GET returns?status=&needs_action=1#
Paginated as returns. Returns waiting for the store come first. needs_action=1 keeps awaiting_drop_off and awaiting_store only.
GET returns/{return}#
GET returns/lookup?order_number=1042 or ?phone=0550000001#
Finds this store's delivered, paid orders for a Counter Return. The phone search matches exactly, and order_number accepts 1042 or #1042.
{ "status": "success", "reasons": [ … ],
"orders": [ { "id": 1042, "order_number": "#1042", "delivered_at": "…", "total_price": 300,
"customer": { "id": 12, "name": "Sara", "phone": "0550000001" },
"window_closes_at": "…", "items": [ …same as returnable… ] } ] }
POST returns — multipart, throttled 30/min#
Opens a Counter Return: order_id, reason, description, lines[i][…] as above, and photos[] with at least one. Opening it records the drop-off and the store's acceptance. The customer then confirms it in their app. The response is 201.
POST returns/{return}/drop-off — multipart#
Records that the goods arrived, and the store's response window starts. The answer can be sent in the same call (response, reason, photos[], as below). If that answer is refused, the drop-off is not recorded either.
POST returns/{return}/respond — multipart#
response: accepted or rejected (required). reason is required when rejecting. photos[] must hold at least one photo when accepting goods that came back.
Accounting#
GET /api/v1/accounting/returns (header X-Accounting-Token)#
Filters: store_id, status, fault_party, start_date / end_date (on refunded_at), per_page. Each row has:
- the reference, order, store, origin, reason, status and fault party
refund_amount,decided_at,refunded_at- the lines (units asked for and approved, unit price)
adjustments: the ledger rows it wrote, each withtype,amount,voided_at,payout_idandsettled_at
Changes to existing endpoints (additive)#
-
Store payloads:
- Merchant store endpoints carry
return_window_days(the store's own value;null= platform default).PUT merchants/auth/stores/{store}andPOST merchants/auth/storesaccept it: an integer from the platform floor up to 365, ornull. GET customers/auth/stores/{store}and the cart'sstoreaddreturn_window_days, the window that applies.
- Merchant store endpoints carry
-
Accounting orders:
payment_statusisrefundedonly once everything charged has gone back. A partially refunded order stayspaid.refunded_amount(SAR) is new.- The same rule now drives
is_paidand the paid/refunded filters everywhere: one returned item no longer makes an order look unpaid.
-
Accounting commission adjustments: new types
return_reversalandreturn_commission_charge, and a neworder_return_idkey. -
Order items:
items[].productis now returned for a product that was deleted after the sale. It used to benull. The item's keys are unchanged. -
App order timelines (customer and merchant) are unchanged. Return steps are recorded on the order timeline for the operator console only, so the apps never receive an event type they do not know.
-
Pushes: new events with
data.type = "return"anddata.return_id,order_id,store_id,user,event:Recipient eventvaluesCustomer return_opened(Counter Return: please confirm),return_dropped_off,return_store_responded,return_approved,return_rejected,return_refunded,return_expiredStore team return_opened,return_confirmed,return_approved,return_rejected