Skip to main content

Returns, Refunds and Exchanges

You can take goods back (return), give money back without goods (refund), or take goods back and sell others in one step (exchange), for any paid order of the outlet.

OperationQuote (records nothing)CreateRead
ReturnPOST /returns/quotePOST /returnsGET /returns, GET /returns/{return_id}
RefundPOST /refundsGET /refunds, GET /refunds/{refund_id}
ExchangePOST /exchanges/quotePOST /exchangesGET /exchanges, GET /exchanges/{exchange_id}

Quotes and creates need X-Outlet-ID and X-User-ID (one exception for your own partner orders: see Your Own Partner Orders: No User, No Shift). The acting user's role must allow the action (the same permission the cashier app checks: returns, refunds or exchanges), and the merchant's plan must include it (see Errors). Reads need only X-Outlet-ID. The lists need a plan feature too (GET /returns and GET /refunds: the refund order list; GET /exchanges: exchanges); reading one record by id does not need it.

Turn the Operations On​

The merchant decides which operations are allowed. In the back office, under Pengaturan pesanan, the merchant turns returns, refunds and exchanges on or off one by one, and can set a day limit for each. A merchant who joined recently has all three off until they turn them on.

AnswerWhen
422 POST_SALE_DISABLEDThe merchant has not turned this operation on.
422 POST_SALE_WINDOW_EXPIREDThe order is older than the day limit of this operation. data.max_days is the limit in days.

Both answers apply to the quote and to the create call. No day limit set means no limit. The merchant must change the setting; sending the request again does not help. These codes are different from 403 PLAN_FEATURE_REQUIRED, which is about the merchant's plan (see Errors).

Open Shift Needed​

A quote or a create runs on the acting user's open shift at the outlet. Without one the call answers 409 SHIFT_NOT_OPEN. Reads need no shift. Open the shift in the cashier app first; read open shifts with GET /shifts. When the user has more than one open shift at the outlet, the oldest one is used.

Your Own Partner Orders: No User, No Shift​

POST /returns/quote, POST /returns and POST /refunds can run without X-User-ID and without an open shift in one narrow case. All three must hold:

  1. The order was created by your application's order source with POST /orders (an order of another application, or an order made in the cashier app, does not count).
  2. Every refund_tenders row names your source's partner rail: the payment_method_id of the order's tender on that rail. Never the cash method and never another payment method. A quote without refund_tenders counts only when every tender of the order is on that rail.
  3. No approval is needed: the outlet has no approval line for the operation (returns for a return and its quote, refunds for a refund), and you send no approval_token.

The call then runs as your order source, the same actor as POST /orders without a user (the record's cashier_id is system:integration-<source_code>). No cash moves, so no shift is touched. The merchant's post-sale policy still applies.

Anything else without X-User-ID answers 400 USER_ID_REQUIRED. Send X-User-ID and keep the user's shift open for every other return or refund. Exchanges always need X-User-ID and an open shift.

When you send X-User-ID, the normal rules apply even on your own partner order: the user's permission is checked and the user needs an open shift (409 SHIFT_NOT_OPEN without one).

The acting user is part of an Idempotency-Key request: a retry must send X-User-ID the same way as the first call (both without it, or both with the same user), or it answers 409 IDEMPOTENCY_KEY_REUSED.

Kitchen Orders​

An order of a kitchen sales type (dine_in, takeaway, pickup, delivery or qr_ordering) can be returned, refunded or exchanged only once the kitchen has served or completed it. Until then a quote or a create answers 409 KITCHEN_NOT_FULFILLED, with the ticket's status in data.kitchen_status (the kitchen_status of GET /orders/{id}, for example new or preparing). Such an order with no kitchen ticket is refused too, with data.kitchen_status: null. Other sales types and the replacement order of an exchange are not affected. The check runs before the approval, so an approval_token is not used up.

Quote First​

Call the quote to show the customer what will happen. A quote records nothing, takes no Idempotency-Key, and is not binding: prices can change before you create the record.

  • POST /returns/quote gives refund_amount (what the customer gets back), payment_fee, refund_rounding, tip_refund_amount, the refund per line, and the tenders that can still be refunded (eligible_refund_tenders) with a suggested split (suggested_refund_tenders).
  • POST /exchanges/quote gives return_total, replacement_total, delta_amount, payment_fee, grand_total (what the customer pays) and refund_amount (what goes back to the customer when the replacement costs less). Send tenders to get the payment fee priced.

All amounts are integers in whole units of currency. You send lines and quantities, not prices: the server prices a return or refund from the original order.

The field names are those of the rest of this API: the paid order is order_id (its id from GET /orders), and a returned line names order_line_id (from GET /orders/{id}) and quantity. A refund sends the money to give back as refund_amount. Money fields have no unit suffix.

A returned line that is not part of the order answers 400 VALIDATION_FAILED with a field error on lines[i].order_line_id (on an exchange, returned_lines[i].order_line_id). Its code is NOT_FOUND; an order_line_id that is not a UUID is INVALID_FORMAT.

Approval Token​

When the outlet needs an approval for the operation, get an approval_token first (see Approvals) and send it as approval_token in the body.

  • No token: 403 APPROVAL_REQUIRED.
  • A token whose approver is not allowed at the outlet: 403 APPROVER_NOT_ELIGIBLE.
  • A token that is unknown, used, expired, or issued for another order, user or type: 403 APPROVAL_TOKEN_INVALID, with the cause in data.reason.

A token works once and only for the order it was issued for. Ask for a new token for each operation.

Cash Refunds and Petty Cash​

A cash refund leaves the petty cash of the user's open shift. When the shift does not hold enough cash the call answers 409 INSUFFICIENT_PETTY_CASH and nothing is recorded. Refund to another tender, or add petty cash in the cashier app, then try again with a new Idempotency-Key.

Idempotency​

POST /returns, POST /refunds and POST /exchanges need an Idempotency-Key. A retry with the same key and the same request returns the first answer. See Idempotency.

After a timeout or a network error, retry with the same key and the same body. You cannot know whether the first call was recorded, and the same key makes that safe: if it was recorded, the stored answer is replayed; if not, the operation runs once. Never switch to a new key for this retry.

An error from the operation is stored under the key too. After APPROVAL_REQUIRED, SHIFT_NOT_OPEN, INSUFFICIENT_PETTY_CASH or CART_REPRICED, fix the cause and send a new key.

409 KITCHEN_NOT_FULFILLED is not stored: once the kitchen has served or completed the order, retry with the same key and the same body.

warning

409 DATA_CONFLICT is different. It means the same request is already recorded or is still being committed, or the acting user's shift was closed while the request ran. A new key is not protected and can create a second return, refund or exchange, which moves money twice. Do not send it again with a new key. Instead:

  • For a return, read GET /returns?order_id= with the id of the original order. Wait a short while and read again. Create again with a new key only when the return is still absent.
  • For a refund, read GET /refunds?order_id= with the id of the original order. For an exchange, read GET /exchanges?order_id=. Wait a short while and read again. Create again with a new key only when the record is still absent. See Reading Returns, Refunds and Exchanges. The status of GET /orders/{id} is only a coarse hint, never proof that your request was or was not recorded.

Sending the same key again only returns the stored 409.

A new record answers 201. A retry with the same Idempotency-Key answers 201 with the same record.

Exchanges​

  • The replacement lines are shaped like the lines of POST /orders. A catalog line names variant_id and is sold at the catalog price. A manual line names a manual_* sku with product_name and manual_price. Any other sku answers 400 VALIDATION_FAILED.
  • What tenders must hold depends on the difference (delta_amount: replacement total minus return total) and on the exchange settings of the merchant, on the "Tukar barang" settings page in Pengaturan pesanan ("Terima pembayaran selisih" and "Refund selisih negatif tukar barang"):
    • The replacement costs more: tenders must add up to delta_amount. When the merchant turned off "Terima pembayaran selisih", the call answers 422 UNPROCESSABLE.
    • The replacement costs less and the merchant refunds the difference ("Refund selisih negatif tukar barang" is on): send exactly one tender, equal to refund_amount. Two tenders, or a total other than refund_amount, answer 422 UNPROCESSABLE ("tender total must equal the refund amount" for a wrong total). QRIS is never accepted. On a partner order, a cash tender is refused with PARTNER_REFUND_ORIGINAL_TENDER above the cash the order was paid with, on the quote as well as on the create call.
    • The replacement costs less and the merchant keeps the difference ("Refund selisih negatif tukar barang" is off, or the totals are equal): tenders must total 0, so send none.
  • When the replacement costs more, each tender names payment_method_id: a cash or custom method from GET /payment-methods, the Terms Payment method of the order (the payment_method_id of its own Terms Payment tender in GET /orders/{id}), or the order source's own partner rail (see the next point). Any other type answers 400 VALIDATION_FAILED on the field, and so does a method that is inactive, hidden or not offered at the outlet (code NOT_ALLOWED, Terms Payment included). A QRIS payment cannot be made through the API, so QRIS is never accepted.
  • An exchange of a partner order can pay the difference on the order source's own partner rail: the Paid on <display_name> method of the source that sent the order with POST /orders. Only the app that owns that order source can use it. GET /payment-methods does not list it; its id is the payment_method_id on the order's tender in GET /orders/{id}, but only when the order was paid on that rail (sent without tenders). An order sent with its own tenders has no rail tender. The rail is hidden by design, so it is not refused for being hidden. The rail of another order source, the order's rail named by another app, and any rail on an order no source sent answer 400 VALIDATION_FAILED with code NOT_ALLOWED on the field.
  • Send expected_payment_fee (the payment_fee of your quote) to be sure of the price. When the fee changed, the call answers 409 CART_REPRICED: quote again.
  • When the replacement costs less, the difference is either paid back to the customer (refund_amount on the exchange record) or kept by the merchant (variance_amount), as the merchant's exchange settings say (they are the same for all outlets of the company). The quote shows the same two fields.
  • The exchange answers delta_payment_method_name, how the difference was settled. When the difference was refunded to the customer, it is the method code: cash, transfer, terms_payment or ewallet (ewallet when the app that owns the order source paid it back on its own partner rail). Otherwise it is the name of the payment method of the replacement order's tender (several names are joined with , ), or an empty string when no method was recorded. To read the tenders of a paid difference, call GET /orders/{replacement_order_id}.
  • replacement_order_id is null when no replacement order was made.

Behaviours to Know​

  • Returning a kitchen sale that has a kitchen ticket moves no stock back.
  • A return or refund needs refund_tenders (up to 10). Each names an eligible tender of the order by payment_method_id (eligible_refund_tenders of POST /returns/quote) or the cash method (from GET /payment-methods). A tender that cannot be refunded shows refundable: false and a reason: integration_qris (a Mekari Pay QRIS payment), points, deposit, customer_balance, legacy_tender or partner_original_tender (the Cash group of a partner order that paid no cash). Naming it answers 422 REFUND_TENDER_NOT_ELIGIBLE, except the Cash group of a partner order, which answers 422 PARTNER_REFUND_ORIGINAL_TENDER.
  • A tender recorded without a payment method shows payment_method_id: null, refundable: false, reason: legacy_tender: refund it in POS, or to another eligible tender. When the order has such a tender, suggested_refund_tenders does not add up to refund_amount: add another eligible tender for the rest, or refund in POS.
  • is_full_return: the return takes back every unit still on the order. is_full_refund: it is a full return of an untouched order; only then is suggested_refund_tenders filled.
  • A partner order (one created with POST /orders) is refunded to the original tender, never in cash. On a return or refund, and on a return quote that sends refund_tenders, a cash row above the cash the order was paid with answers 422 PARTNER_REFUND_ORIGINAL_TENDER; on an exchange, a cash tender for the difference above that cash answers the same.
  • A refund cannot be more than what is left to refund on the order (422 UNPROCESSABLE).
  • A return quote shows the options in eligible_refund_tenders. When you send refund_tenders on a quote, each must be an eligible tender (422 REFUND_TENDER_NOT_ELIGIBLE otherwise); on a partner order a cash amount above the cash the order was paid with answers 422 PARTNER_REFUND_ORIGINAL_TENDER, and on a Dine In round an amount above what its table settlement tender can still take answers 422 REFUND_TENDER_EXCEEDS_REMAINING, already on the quote. The other amounts are checked only when the return or refund is created: an amount that is not above 0 answers 422 REFUND_TENDER_INVALID_AMOUNT; the same payment method twice, 422 REFUND_TENDER_DUPLICATE; more than one tender when the refund is not a full refund of an untouched order, 422 REFUND_MULTI_TENDER_REQUIRES_FULL; and amounts that do not add up to the refund, 422 REFUND_TENDER_SUM_MISMATCH.
  • An order paid by terms payment must be fully settled first (422 UNPROCESSABLE). Its eligible_refund_tenders hold the tenders of its settlement payments in place of the Terms Payment line.

Reading Returns, Refunds and Exchanges​

  • GET /returns, GET /refunds and GET /exchanges list the records of the outlet in X-Outlet-ID, newest first, as a page with items and next_cursor. limit is 1 to 100 (default 20). Records made in the cashier app are listed too, not only those you created through the API.
  • Send order_id to list only the returns, refunds or exchanges of one order. An order can have more than one. An order_id that is not a UUID answers 400 VALIDATION_FAILED.
  • Send next_cursor back as cursor to get the next page. A cursor works only for the same list, the same order_id filter and the same limit; any other cursor answers 400 INVALID_CURSOR.
  • GET /returns/{return_id}, GET /refunds/{refund_id} and GET /exchanges/{exchange_id} read one record by its own id, the id returned by POST /returns, POST /refunds or POST /exchanges. This is not the order id, because one order can have several returns, refunds or exchanges. An unknown id, or an id of another outlet, answers 404 NOT_FOUND.
  • The body of a read is the same as the body of the matching POST answer, for a new record and for a repeated request. Exchange lines come ordered by leg (the replacement lines first).
  • The lists need a plan feature (see the top of this page), otherwise 403 PLAN_FEATURE_REQUIRED. Reading one record by id does not need it. Reads need only X-Outlet-ID: no X-User-ID and no open shift.

Webhooks​

The order.returned, order.refunded and order.exchanged events are sent as for any return, refund or exchange. You do not receive the events caused by your own calls; see Webhooks. Post-sale events carry the order_id of the original order and return_id, refund_id or exchange_id. To get the record, call GET /returns/{return_id}, GET /refunds/{refund_id} or GET /exchanges/{exchange_id}.

Error Codes​

HTTPCodeWhen
400VALIDATION_FAILEDA field is wrong (for example an order_id filter that is not a UUID, or a returned line that is not part of the order), or an exchange tender names a method that is not cash, custom or Terms Payment
400INVALID_CURSORThe cursor of a return, refund or exchange list is bad, or belongs to another list, filter or limit
400IDEMPOTENCY_KEY_REQUIREDA create call without Idempotency-Key
403APPROVAL_REQUIREDApproval is needed and no approval_token was sent
403APPROVER_NOT_ELIGIBLEThe approver of the token is not allowed at the outlet
403APPROVAL_TOKEN_INVALIDThe token cannot be used for this request. data.reason tells why
403PLAN_FEATURE_REQUIREDThe merchant's plan lacks returns, refunds or exchanges. data.reason is not_in_plan or disabled, and data.feature is the feature key
404NOT_FOUNDThe original order, return, refund or exchange is unknown or belongs to another outlet
409SHIFT_NOT_OPENThe acting user has no open shift at the outlet
409INSUFFICIENT_PETTY_CASHA cash refund is more than the shift's petty cash
409DINE_IN_ROUND_UNPAIDThe order is a Dine In round whose bill is not paid yet
409SETTLEMENT_SALE_NOT_REVERSIBLEThe order is the payment record of a whole Dine In table; return, refund or exchange its rounds instead
409KITCHEN_NOT_FULFILLEDThe order has a kitchen sales type and its kitchen ticket is not served or completed yet (or it has none); data.kitchen_status has its status, null without a ticket
409CART_REPRICEDexpected_payment_fee differs from the fee the server priced
409DATA_CONFLICTThe same request is already recorded or still being committed, or the shift closed meanwhile. Do not send it again with a new Idempotency-Key; see Idempotency above
422POST_SALE_DISABLEDThe merchant has not turned this operation on in Pengaturan pesanan (off for new merchants)
422POST_SALE_WINDOW_EXPIREDThe order is older than the day limit of the operation. data.max_days is the limit in days
422UNPROCESSABLEA business rule failed; read message (for example, nothing left to refund)
422PARTNER_REFUND_ORIGINAL_TENDERA partner order is refunded to the original tender, not in cash
422REFUND_TENDER_INVALID_AMOUNTA refund tender amount is not above 0
422REFUND_TENDER_DUPLICATEA payment method appears more than once in refund_tenders
422REFUND_TENDER_NOT_ELIGIBLEA refund tender is not a refundable tender of the order, and not the cash method
422REFUND_MULTI_TENDER_REQUIRES_FULLMore than one tender is allowed only for a full refund of an untouched order
422REFUND_TENDER_SUM_MISMATCHThe refund_tenders amounts do not add up to the refund
422REFUND_TENDER_EXCEEDS_REMAININGA Dine In round's share of a table settlement tender cannot take that much more; refund the rest in cash