RMA
Hyvä POS is in closed beta
Hyvä POS is currently in a closed beta (pilot phase) with a small group of merchants. It is not yet generally available: the App Store release follows the pilot, and features and configuration may still change, possibly in backwards-incompatible ways, before the general release. Want to take part? Sign up at hyva.io/pos.
| Method | Route | Purpose |
|---|---|---|
GET |
/V1/pos/rma/order/:orderId |
Existing returns for an order |
GET |
/V1/pos/rma/:rmaId |
A single return |
GET |
/V1/pos/rma/open |
Open returns across all orders (the pending-returns inbox) |
GET |
/V1/pos/rma/options |
The active provider's reasons, conditions and resolutions, with ids |
GET |
/V1/pos/rma/eligibility/:orderId |
Whether the order and each line can still be returned, and for which resolutions |
POST |
/V1/pos/rma |
Create a return; optionally auto-approve and receive in one call |
PUT |
/V1/pos/rma/:rmaId/status |
Transition a return's status |
POST |
/V1/pos/rma/:rmaId/comment |
Add a comment |
POST |
/V1/pos/rma/:rmaId/cancel-refund |
Cancel a parked in-store refund: back to received, settlement chosen again |
POST |
/V1/pos/rma/:rmaId/close |
Close an open return; what the receive restocked goes back out |
POST |
/V1/pos/rma/:rmaId/receive |
Staged flow: record physical receipt and inventory routing |
POST |
/V1/pos/rma/:rmaId/resolve |
Staged flow: record per-line resolutions |
POST |
/V1/pos/rma/:rmaId/settle |
Settle an awaiting_refund return: create the credit memo, close |
POST |
/V1/pos/rma/process |
Collapsed flow: create RMA + credit memo + resolution in one call |
POST |
/V1/pos/rma/:rmaId/process |
Fast-track an existing (e.g. online-submitted) return in store |
POST |
/V1/pos/refunds/preview |
The credit memo a refund would book, never saved |
GET |
/V1/pos/rma/refund-attempts/:attemptId |
One refund attempt through the original payment's gateway |
POST |
/V1/pos/rma/refund-attempts/:attemptId/reconcile |
Ask the gateway again about an uncertain attempt |
POST |
/V1/pos/rma/refund-attempts/:attemptId/resolve |
Record what the provider's own records show for an uncertain attempt |
All routes require the Hyva_Pos::locations ACL resource. There is no separate approve ACL; cashier authority is enforced by the POS staff-role check on the device. The service delegates to the single configured returns provider (a bridge module implementing RmaProviderInterface, such as the Adobe Commerce RMA bridge). POST /V1/pos/rma/process also works on stores without any RMA module: it skips RMA creation and creates the credit memo directly.
Status Vocabulary
Every bridge reports status using the canonical vocabulary from Hyva\Pos\Api\RmaStatusVocabulary; the vendor's own human-readable name goes in status_label.
- Open (in flight, needs attention):
pending,authorized,partially_authorized,received,received_partially,approved,awaiting_refund - Terminal (settled end state):
resolved,closed,processed_closed,rejected,denied
Terminal states are sticky: a provider never moves a return out of one. This makes settle retries idempotent: settling an already-closed return is a no-op, not an error.
The Return Shape
Every route that returns a return uses this shape:
{
"id": 18,
"increment_id": "000000018",
"order_id": 2043,
"order_increment_id": "000002043",
"customer_id": 1287,
"status": "awaiting_refund",
"status_label": "Awaiting Refund",
"items": [
{
"entity_id": 31,
"order_item_id": 5120,
"product_name": "Strive Shoulder Pack",
"product_sku": "24-MB04",
"qty_requested": 2,
"qty_authorized": 2,
"qty_approved": 2,
"qty_returned": 2,
"reason": "Wrong Size/Color",
"condition": "Unopened",
"resolution": "Refund",
"status": "authorized"
}
],
"comments": [],
"created_at": "2026-08-12 11:02:44",
"updated_at": "2026-08-12 11:02:47",
"refund_method": "cash",
"credit_memo_id": null,
"credit_memo_increment_id": null
}
refund_method is the settlement channel (original_payment, cash, terminal, exchange, store_credit), derived server-side; null when unknown. credit_memo_id / credit_memo_increment_id are set once the return has been settled with a memo.
Since module 1.3.0 the shape carries more fields where they apply:
| Field | Meaning |
|---|---|
pos_sale_id |
The register sale the return was created for; absent for a return created without one |
reused |
On a create's answer only: true when the sale already had a return and that return is answered with nothing created, false when this create made it |
ready |
For a return created with a sale id: true once the create's initialization (approve, receive, park or settle) has finished. A return that reads false is held, never settled |
refund_legs |
The credit legs the settle's memo gave back, per kind |
base_refund_total |
The settle's refund before credit netting, base currency |
base_handback |
What the memo sent back by the original tender: the refund less every credit leg |
refund_difference |
One sentence on how the booked legs differ from the expectedLegs the settle was sent; null when they match or none were sent |
refund_attempt_id / refund_attempt_state |
The return's newest refund attempt and its state (started, booked, uncertain, failed); absent when the return has none |
Read Routes
GET /V1/pos/rma/order/:orderId: array of returns for one order. The POS order-detail view uses it to show return history and block double returns.GET /V1/pos/rma/:rmaId: a single return.GET /V1/pos/rma/open?limit=200: returns in non-terminal statuses across all orders, one request. Backs the POS pending-returns inbox, so a cashier can complete an online-initiated return without looking up the source order. When thelimitcap is hit, offer "search by order number" for older returns.
Return Options and Eligibility
GET /V1/pos/rma/options?storeId=1: the active provider's reasons, item conditions and resolutions, in the backend's order, with labels at the store view (the request's store view when storeId is omitted):
{
"provider_code": "pos_builtin",
"store_id": 1,
"reasons": [{ "id": "Wrong Size/Color", "label": "Wrong Size/Color", "behavior": null, "is_store_credit": null }],
"conditions": [{ "id": "Unopened", "label": "Unopened", "behavior": null, "is_store_credit": null }],
"resolutions": [
{ "id": "Refund", "label": "Refund", "behavior": "refund", "is_store_credit": false },
{ "id": "Exchange", "label": "Exchange", "behavior": "exchange", "is_store_credit": false },
{ "id": "Store Credit", "label": "Store Credit", "behavior": "refund", "is_store_credit": true }
]
}
The built-in provider (abridged above) uses each label as its id; a vendor bridge answers the backend's own ids. A create sends an option's id back as reason_id, condition_id or resolution_id. A resolution's behavior (refund, exchange or unsupported) is what the register does with it, decided by the provider from the backend's own record; is_store_credit is true on a refund to store credit. Both are null on reasons and conditions.
GET /V1/pos/rma/eligibility/:orderId: whether the order, and each of its lines, can be returned now. For example, on a store whose Amasty Return Rules allow a refund (resolution id 1) within 14 days and an exchange (id 2) within 30, at day 20:
{
"order_id": 2043,
"outcome": "allowed",
"reason_code": null,
"reason": null,
"period_days": 30,
"lines": [
{
"order_item_id": 5120,
"name": "Strive Shoulder Pack",
"outcome": "allowed",
"reason_code": null,
"reason": null,
"qty_returnable": 2,
"period_days": 30,
"allowed_resolution_ids": ["2"],
"resolution_periods": [
{ "resolution_id": "1", "period_days": 14 },
{ "resolution_id": "2", "period_days": 30 }
]
}
]
}
outcome is allowed or forbidden; a forbidden order or line names a reason_code (quantity, not_returnable, outside_period, vendor_rule) and a sentence. period_days 0 means no window applies. qty_returnable is null when the provider sets no limit. allowed_resolution_ids lists the resolutions a line can still be returned for (empty on a forbidden line, null when the backend sets no limit per resolution); resolution_periods is set where the backend keeps a period per resolution, such as Amasty's Return Rules. A line the answer does not list carries no restriction of its own. The built-in provider allows every resolution.
Create a Return
POST /V1/pos/rma: creates a return. This is the endpoint behind the POS walk-in return wizard.
{
"orderId": 2043,
"customerId": 1287,
"items": "[{\"order_item_id\":5120,\"qty_requested\":2,\"reason\":\"Wrong Size/Color\",\"condition\":\"Unopened\",\"resolution\":\"Refund\",\"inspected_condition\":\"pristine\",\"return_to_stock\":true}]",
"comment": "Walk-in return",
"autoApprove": true,
"locationId": 3,
"refundMethod": "cash",
"posSaleId": "9F3C2A1B-7D4E-4A61-8C0B-2E5F1A9D3C77"
}
items is a JSON-encoded string, not a nested array; the service contract declares it as a string. Each row carries:
| Field | Purpose |
|---|---|
order_item_id |
The order item being returned |
qty_requested |
Quantity the customer is returning (drives the refund) |
reason |
Return reason, free vocabulary (the POS sends e.g. Wrong Item, Defective, Wrong Size/Color) |
condition |
Declared condition (Unopened, Opened, Damaged) |
resolution |
Per-line resolution label (Refund, Exchange, Store Credit) |
reason_id / condition_id / resolution_id |
Optional: the option ids GET /V1/pos/rma/options answers, in place of the labels |
inspected_condition |
Inspection verdict: pristine, damaged, or dispose. Ignored unless autoApprove is true |
return_to_stock |
Whether the line restocks; defaults to true when absent |
qty_received |
Optional partial receive; see the quantity contract below. Capped at qty_requested |
target_source_code |
Optional explicit MSI destination source, overriding the condition-based routing |
With autoApprove: false (the default) the return is created in pending for the back office to process. With autoApprove: true the server moves it through requested → approved → items_received in one round-trip using the inspection metadata on each row: pristine lines restock to their origin source, damaged lines route to the location's configured damaged-stock source, dispose lines are written off. locationId is required in this mode because damaged-stock routing reads the location's damaged_stock_source_code.
refundMethod (honored only with autoApprove: true) decides what happens next: original_payment settles synchronously through the original gateway and closes the return in the same call; cash, terminal, exchange, and store_credit leave it in awaiting_refund for the cashier to finish at the register.
Response: the created return in the shape above. The auto-approve fast path is best effort: if the approve or receive step fails, the return is already persisted and the error surfaces to the caller so the client can show a partial-success warning; a manager finishes it from admin.
posSaleId (optional, at most 64 characters) is the register's sale id, the same one a sale's order carries as pos_sale_id, and makes the create idempotent: one return per sale. The module records the sale with the provider's return in one transaction and runs the return's initialization (approve, receive, park or settle, as autoApprove and refundMethod ask) under Magento's order mutex. A create whose sale is already recorded creates nothing and answers that return with reused: true, after finishing an initialization the first create had not finished; two creates of one sale at once end with one return. The answer of a create with a sale id carries pos_sale_id, ready and reused. A create without one is unchanged.
Before anything is created, the provider's eligibility is read again. Refusals:
| HTTP | parameters.outcome |
When |
|---|---|---|
400 |
return_not_eligible |
The order or a requested line is forbidden, or a quantity exceeds what a line can still return (a row's own, and the rows of one order item summed). parameters carries reason_code, period_days and order_item_id (0 for the order). Outside the return period the message names the period and the Magento admin as the way out; there is no override. A resolution whose behavior is unsupported (reason_code resolution_unsupported) or that the line's allowed_resolution_ids leave out (resolution_not_allowed) is refused the same way, with resolution_id and allowed_resolution_ids |
409 |
sale_return_conflict |
The sale id is recorded for another order, under another provider, or its return can no longer be read |
409 |
sale_return_being_created |
The sale's return exists but its initialization cannot be finished now (the order lock is held past the wait, or the return is in a status the module does not know) |
The Quantity Contract
The refund follows the requested/approved quantity, not the received one. A cashier receiving 3 of 5 items as restockable still refunds all 5; only the restock drops to the received count. qty_received feeds inventory routing, never the credit memo. Do not build integrations that expect a partial receive to shrink the refund.
Status and Comments
PUT /V1/pos/rma/:rmaId/status: body { "status": "approved", "comment": "Approved at the register" } (comment optional). Returns the updated return. Use vocabulary codes for status.
POST /V1/pos/rma/:rmaId/comment: body { "comment": "Customer will pick a replacement tomorrow", "notifyCustomer": false }. Returns true.
POST /V1/pos/rma/:rmaId/cancel-refund: body { "comment": "Customer wants it on the card" } (comment optional). Moves an awaiting_refund return back to received: the items stay received and restocked, no credit memo exists, and the settlement is chosen again. The audit trail gets the line "In-store refund cancelled at the register." followed by the comment, and refund_method reads null until the return is parked again. A return already on received is returned unchanged; any other status is refused with a 400.
POST /V1/pos/rma/:rmaId/close: body { "comment": "Customer kept the goods" } (comment optional). Closes an open return. Every line the register restocked when it received the return goes back out of the source it went to, never below zero; the audit line reads "Return closed at the register. Taken back out of stock: 1 × sku from source." followed by the comment. No credit memo is created. A return already closed, processed or rejected is returned unchanged.
Staged Flow: Receive and Resolve
For back-office style processing the lifecycle can be walked step by step instead of collapsed into create.
POST /V1/pos/rma/:rmaId/receive: records physical receipt and routes inventory; transitions approved → items_received. itemsJson rows are keyed by rma_item_id (the entity_id from the return's items, not the order item id):
{
"itemsJson": "[{\"rma_item_id\":31,\"condition\":\"pristine\",\"qty_received\":2,\"return_to_stock\":true}]",
"locationId": 3
}
POST /V1/pos/rma/:rmaId/resolve: records each line's resolution (refund, store_credit, exchange, repair, dispose) and triggers the downstream action; transitions to resolved once every line carries one:
{
"resolutionsJson": "[{\"rma_item_id\":31,\"resolution_mode\":\"refund\",\"refund_method\":\"cash\"}]"
}
Settle a Refund
POST /V1/pos/rma/:rmaId/settle: finishes an awaiting_refund return once the cashier has handed money back, reversed on the card terminal, or closed the exchange sale. Creates the credit memo (offline for cash / terminal / exchange / store_credit, online for a resumed original_payment) and closes the return.
{
"method": "terminal",
"reference": "re_4qqhO89gsT",
"rawPayload": "{\"resource\":\"refund\",\"id\":\"re_4qqhO89gsT\"}",
"adjustmentNegative": 5.0
}
reference(optional): PSP refund id or linked exchange-order increment. When present and the memo creates successfully, asales_payment_transactionrow of typerefundis written so the refund shows in Magento's Transactions tab and accounting exports. Omit for cash.rawPayload(optional): raw PSP response stored on the transaction'sadditional_information.adjustmentPositive/adjustmentNegative(optional, ex-tax): goodwill credit on top of, or a restocking fee subtracted from, the items refund, stamped on the memo's adjustment fields.shippingAmount(optional): explicit refunded shipping; omitted or zero lets Magento distribute per its memo defaults.expectedLegs(optional): the credit legs of the refund preview the register acted on. The settle books what Magento's collectors produce whatever these say; a difference is recorded on the return as a status-history comment and answered inrefund_difference.notifyCustomer(optional, defaultfalse): send Magento's own credit memo email, the one core REST'snotifyand the admin's Email Copy of Credit Memo send. Honored fororiginal_payment,store_creditandterminal; ignored forcash,exchangeandmanual_card. A retry that reuses the memo of an earlier attempt sends nothing.
Response: the settled return, now carrying credit_memo_id and credit_memo_increment_id, and the booking: refund_legs, base_refund_total, base_handback, refund_difference, refund_attempt_id and refund_attempt_state. The credit memo quantities follow the quantity contract above. Idempotent: settling an already-closed return returns it unchanged, so it is safe to retry after a timeout. A memo Magento refuses because another refund used up the order answers that nothing is left to refund, names the memo that took it, and leaves the return parked with a comment.
A return created with a sale id whose initialization has not finished (ready: false) is finished first and then settled; when that cannot happen now the settle answers 409 return_not_ready with return_id and books nothing.
Refund Preview
POST /V1/pos/refunds/preview: builds the credit memo a refund would create, through every vendor's collectors and with the store-credit provider's preparation applied as the settle applies it, and never saves it. The register reads its refund cap and handback from it.
{
"orderId": 2043,
"items": "[{\"order_item_id\":5120,\"qty\":1}]",
"channel": "original",
"adjustmentPositive": 0,
"adjustmentNegative": 0,
"shippingAmount": 0
}
channel is original (the original tender) or store_credit. The answer carries order_id, channel, currency_code, base_refund_total / refund_total (the refund before credit netting), base_handback / handback (what goes back by the original tender: the refund less every credit leg), base_returnable_max / returnable_max (the most the order can still refund), legs (the credit legs the refund gives back, per kind) and lines (per line: order_item_id, qty, base_row_total, base_credit_share, and for a points discount base_points_amount and points).
Refund Attempts
A refund through the original payment's gateway is recorded as a refund attempt before the gateway is called, and the memo and the attempt's move to booked commit together. After a rollback the attempt is failed when the gateway was not called or provably did not refund, and uncertain when it may have refunded. An uncertain attempt answers the settle with 409, parameters outcome: gateway_uncertain, refund_attempt_id, refund_attempt_state and return_id; the return stays parked and nothing is refunded for it until the attempt is reconciled or resolved. An order paid through Mollie's Orders API is refused before an attempt is written ("Refund this order from the Magento admin: its Mollie payment was made through the Orders API, which the register cannot track.").
GET /V1/pos/rma/refund-attempts/:attemptId: one attempt:attempt_id,order_id,return_id,payment_method,channel,state(started,booked,uncertain,failed),base_amount,credit_memo_id,credit_memo_increment_id,gateway_outcome,gateway_reference,message,resolved_by,resolution_note,created_at,updated_at.POST /V1/pos/rma/refund-attempts/:attemptId/reconcile: asks the gateway again. Refunded: the return's memo is booked without a second gateway call and the attempt answersbooked. Not refunded:failed. A gateway that still cannot answer leaves ituncertain.POST /V1/pos/rma/refund-attempts/:attemptId/resolve: body{ "resolution": "refunded", "staffName": "Anna de Vries", "note": "Refund visible in the provider dashboard" },resolutionrefundedornot_refunded. Recorded on the attempt and as a comment on the return. Refunded books the memo without calling the gateway; not refunded fails the attempt and the next settle refunds anew.
All three answer the attempt in the shape above. Mollie's and PAY.'s Magento modules keep nothing a refund could be found by afterwards, so their uncertain attempts are resolved by hand.
Collapsed Processing
POST /V1/pos/rma/process: create RMA (when a returns module is available), create the credit memo, and handle the resolution in one call. Body: { "orderId": 2043, "customerId": 1287, "items": "[{\"order_item_id\":5120,\"qty\":2,\"reason\":\"Defective\",\"condition\":\"Opened\",\"resolution\":\"Refund\"}]", "resolution": "refund", "isOnline": true, "comment": null }. resolution is the primary resolution (refund, store_credit, or exchange); isOnline: true refunds through the payment gateway, false creates an offline memo.
POST /V1/pos/rma/:rmaId/process: the same fast-track for an existing return (for example one submitted online). Body: { "resolution": "refund", "isOnline": true }.
Both respond with a process result rather than a return:
{
"success": true,
"error": null,
"rma_id": 18,
"rma_increment_id": "000000018",
"credit_memo_id": 77,
"credit_memo_increment_id": "000000077",
"refund_total": 118.9,
"store_credit_issued": 0.0,
"exchange_credit_amount": 0.0,
"is_online_refund": true,
"payment_method": "mollie_methods_ideal",
"transaction_id": "tr_WDqYK6vllg"
}
rma_id is null on stores without a returns module. The active provider runs the return; only its ReturnNotAttemptedException, raised before it wrote anything, lets the call go on to the next provider and then to the core's own credit memo. Any other exception from the provider stops the call with nothing else attempted and a status-history comment on the order naming the provider and the failure: a definite refusal (LocalizedException) answers HTTP 400 with its message, an uncertain gateway refund 409 gateway_uncertain as the settle answers it, anything else 500. When no provider attempts an existing return, /V1/pos/rma/:rmaId/process answers 200 with success: false. A return the provider's eligibility forbids answers 400 return_not_eligible, as on create.
Error Behavior
Standard Magento web API envelope: 401 for a bad token, 404 (NoSuchEntityException) for unknown return or order ids, 400 for invalid payloads and provider-rejected transitions (LocalizedException). The 409 answers are listed with the routes that give them above.