Skip to content

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 the limit cap 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, a sales_payment_transaction row of type refund is 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's additional_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 in refund_difference.
  • notifyCustomer (optional, default false): send Magento's own credit memo email, the one core REST's notify and the admin's Email Copy of Credit Memo send. Honored for original_payment, store_credit and terminal; ignored for cash, exchange and manual_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 answers booked. Not refunded: failed. A gateway that still cannot answer leaves it uncertain.
  • POST /V1/pos/rma/refund-attempts/:attemptId/resolve: body { "resolution": "refunded", "staffName": "Anna de Vries", "note": "Refund visible in the provider dashboard" }, resolution refunded or not_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.