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.
| Operation | Quote (records nothing) | Create | Read |
|---|---|---|---|
| Return | POST /returns/quote | POST /returns | GET /returns, GET /returns/{return_id} |
| Refund | POST /refunds | GET /refunds, GET /refunds/{refund_id} | |
| Exchange | POST /exchanges/quote | POST /exchanges | GET /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.
| Answer | When |
|---|---|
422 POST_SALE_DISABLED | The merchant has not turned this operation on. |
422 POST_SALE_WINDOW_EXPIRED | The 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:
- 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). - Every
refund_tendersrow names your source's partner rail: thepayment_method_idof the order's tender on that rail. Never the cash method and never another payment method. A quote withoutrefund_tenderscounts only when every tender of the order is on that rail. - 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/quotegivesrefund_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/quotegivesreturn_total,replacement_total,delta_amount,payment_fee,grand_total(what the customer pays) andrefund_amount(what goes back to the customer when the replacement costs less). Sendtendersto 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 indata.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.
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, readGET /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. ThestatusofGET /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 namesvariant_idand is sold at the catalog price. A manual line names amanual_*skuwithproduct_nameandmanual_price. Any otherskuanswers400 VALIDATION_FAILED. - What
tendersmust 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:
tendersmust add up todelta_amount. When the merchant turned off "Terima pembayaran selisih", the call answers422 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 thanrefund_amount, answer422 UNPROCESSABLE("tender total must equal the refund amount" for a wrong total). QRIS is never accepted. On a partner order, acashtender is refused withPARTNER_REFUND_ORIGINAL_TENDERabove 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):
tendersmust total 0, so send none.
- The replacement costs more:
- When the replacement costs more, each tender names
payment_method_id: acashorcustommethod fromGET /payment-methods, the Terms Payment method of the order (thepayment_method_idof its own Terms Payment tender inGET /orders/{id}), or the order source's own partner rail (see the next point). Any other type answers400 VALIDATION_FAILEDon the field, and so does a method that is inactive, hidden or not offered at the outlet (codeNOT_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 withPOST /orders. Only the app that owns that order source can use it.GET /payment-methodsdoes not list it; its id is thepayment_method_idon the order's tender inGET /orders/{id}, but only when the order was paid on that rail (sent withouttenders). An order sent with its owntendershas 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 answer400 VALIDATION_FAILEDwith codeNOT_ALLOWEDon the field. - Send
expected_payment_fee(thepayment_feeof your quote) to be sure of the price. When the fee changed, the call answers409 CART_REPRICED: quote again. - When the replacement costs less, the difference is either paid back to the customer (
refund_amounton 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_paymentorewallet(ewalletwhen 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, callGET /orders/{replacement_order_id}. replacement_order_idisnullwhen 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 bypayment_method_id(eligible_refund_tendersofPOST /returns/quote) or the cash method (fromGET /payment-methods). A tender that cannot be refunded showsrefundable: falseand areason:integration_qris(a Mekari Pay QRIS payment),points,deposit,customer_balance,legacy_tenderorpartner_original_tender(the Cash group of a partner order that paid no cash). Naming it answers422 REFUND_TENDER_NOT_ELIGIBLE, except the Cash group of a partner order, which answers422 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_tendersdoes not add up torefund_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 issuggested_refund_tendersfilled.- 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 sendsrefund_tenders, a cash row above the cash the order was paid with answers422 PARTNER_REFUND_ORIGINAL_TENDER; on an exchange, acashtender 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 sendrefund_tenderson a quote, each must be an eligible tender (422 REFUND_TENDER_NOT_ELIGIBLEotherwise); on a partner order a cash amount above the cash the order was paid with answers422 PARTNER_REFUND_ORIGINAL_TENDER, and on a Dine In round an amount above what its table settlement tender can still take answers422 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 answers422 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). Itseligible_refund_tendershold the tenders of its settlement payments in place of the Terms Payment line.
Reading Returns, Refunds and Exchanges
GET /returns,GET /refundsandGET /exchangeslist the records of the outlet inX-Outlet-ID, newest first, as a page withitemsandnext_cursor.limitis 1 to 100 (default 20). Records made in the cashier app are listed too, not only those you created through the API.- Send
order_idto list only the returns, refunds or exchanges of one order. An order can have more than one. Anorder_idthat is not a UUID answers400 VALIDATION_FAILED. - Send
next_cursorback ascursorto get the next page. A cursor works only for the same list, the sameorder_idfilter and the samelimit; any other cursor answers400 INVALID_CURSOR. GET /returns/{return_id},GET /refunds/{refund_id}andGET /exchanges/{exchange_id}read one record by its ownid, theidreturned byPOST /returns,POST /refundsorPOST /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, answers404 NOT_FOUND.- The body of a read is the same as the body of the matching
POSTanswer, for a new record and for a repeated request. Exchangelinescome 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 byiddoes not need it. Reads need onlyX-Outlet-ID: noX-User-IDand 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
| HTTP | Code | When |
|---|---|---|
400 | VALIDATION_FAILED | A 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 |
400 | INVALID_CURSOR | The cursor of a return, refund or exchange list is bad, or belongs to another list, filter or limit |
400 | IDEMPOTENCY_KEY_REQUIRED | A create call without Idempotency-Key |
403 | APPROVAL_REQUIRED | Approval is needed and no approval_token was sent |
403 | APPROVER_NOT_ELIGIBLE | The approver of the token is not allowed at the outlet |
403 | APPROVAL_TOKEN_INVALID | The token cannot be used for this request. data.reason tells why |
403 | PLAN_FEATURE_REQUIRED | The merchant's plan lacks returns, refunds or exchanges. data.reason is not_in_plan or disabled, and data.feature is the feature key |
404 | NOT_FOUND | The original order, return, refund or exchange is unknown or belongs to another outlet |
409 | SHIFT_NOT_OPEN | The acting user has no open shift at the outlet |
409 | INSUFFICIENT_PETTY_CASH | A cash refund is more than the shift's petty cash |
409 | DINE_IN_ROUND_UNPAID | The order is a Dine In round whose bill is not paid yet |
409 | SETTLEMENT_SALE_NOT_REVERSIBLE | The order is the payment record of a whole Dine In table; return, refund or exchange its rounds instead |
409 | KITCHEN_NOT_FULFILLED | The 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 |
409 | CART_REPRICED | expected_payment_fee differs from the fee the server priced |
409 | DATA_CONFLICT | The 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 |
422 | POST_SALE_DISABLED | The merchant has not turned this operation on in Pengaturan pesanan (off for new merchants) |
422 | POST_SALE_WINDOW_EXPIRED | The order is older than the day limit of the operation. data.max_days is the limit in days |
422 | UNPROCESSABLE | A business rule failed; read message (for example, nothing left to refund) |
422 | PARTNER_REFUND_ORIGINAL_TENDER | A partner order is refunded to the original tender, not in cash |
422 | REFUND_TENDER_INVALID_AMOUNT | A refund tender amount is not above 0 |
422 | REFUND_TENDER_DUPLICATE | A payment method appears more than once in refund_tenders |
422 | REFUND_TENDER_NOT_ELIGIBLE | A refund tender is not a refundable tender of the order, and not the cash method |
422 | REFUND_MULTI_TENDER_REQUIRES_FULL | More than one tender is allowed only for a full refund of an untouched order |
422 | REFUND_TENDER_SUM_MISMATCH | The refund_tenders amounts do not add up to the refund |
422 | REFUND_TENDER_EXCEEDS_REMAINING | A Dine In round's share of a table settlement tender cannot take that much more; refund the rest in cash |