Tote developers
All guides

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|en localizes messages and labels.
  • Money is decimal SAR.
  • Errors come back as {"status": "error", "message": "..."}.
  • Validation errors are Laravel's 422 with errors.

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) and commission_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 VAT the 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_reversal and store_fault_fee as 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_image are the product as it was sold, not as it is now.
  • approved_quantity is null until the admin decides. 0 means that line was turned down.
  • refund_amount is set on approval.
  • next_step.action is one of bring_goods, wait_for_store, confirm, wait_for_review, wait_for_refund, or null for 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#

API reference

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#

API reference

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#

API reference

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#

API reference

Paginated as returns. Returns waiting for the store come first. needs_action=1 keeps awaiting_drop_off and awaiting_store only.

GET returns/{return}#

API reference

GET returns/lookup?order_number=1042 or ?phone=0550000001#

API reference

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#

API reference

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#

API reference

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#

API reference

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

API reference

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 with type, amount, voided_at, payout_id and settled_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} and POST merchants/auth/stores accept it: an integer from the platform floor up to 365, or null.
    • GET customers/auth/stores/{store} and the cart's store add return_window_days, the window that applies.
  • Accounting orders:

    • payment_status is refunded only once everything charged has gone back. A partially refunded order stays paid.
    • refunded_amount (SAR) is new.
    • The same rule now drives is_paid and the paid/refunded filters everywhere: one returned item no longer makes an order look unpaid.
  • Accounting commission adjustments: new types return_reversal and return_commission_charge, and a new order_return_id key.

  • Order items: items[].product is now returned for a product that was deleted after the sale. It used to be null. 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" and data.return_id, order_id, store_id, user, event:

    Recipient event values
    Customer return_opened (Counter Return: please confirm), return_dropped_off, return_store_responded, return_approved, return_rejected, return_refunded, return_expired
    Store team return_opened, return_confirmed, return_approved, return_rejected