Receipts
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.
Every completed POS sale produces a receipt row in hyva_pos_receipt. The app syncs it to Magento so the receipt can be re-fetched on another device, listed in admin, served on the public QR receipt page, and emailed to the customer.
| Method | Route | Purpose |
|---|---|---|
POST |
/V1/pos/receipts |
Sync a completed sale's receipt (idempotent by sale id) |
GET |
/V1/pos/receipts/:receiptId |
Fetch one receipt by its numeric id |
GET |
/V1/pos/receipts/by-increment-id/:incrementId |
Look up by Magento order increment id (cross-terminal restore) |
GET |
/V1/pos/receipts |
List receipts via standard searchCriteria |
POST |
/V1/pos/receipts/email |
Email the rendered receipt to the customer |
All routes require the Hyva_Pos::receipts ACL resource on the integration token. POST /V1/pos/receipts also requires an active PRO terminal license - without one the route returns 401, and the app holds receipts in its local queue until the license heartbeat unlocks PRO.
Sync a Receipt
POST /V1/pos/receipts
Called by the app's receipt queue for every completed sale, before the Magento order is placed, so the order can reference an existing receipt. The body wraps a single receipt object. A trimmed cash-sale example with the load-bearing fields:
{
"receipt": {
"sale_id": "9A9E4AF3-0E4E-4B2A-9D3E-1C2B3D4E5F60",
"terminal_id": 3,
"location_id": 1,
"staff_id": 7,
"staff_name": "Anna",
"terminal_name": "Front Desk 01",
"location_name": "Amsterdam Store",
"order_increment_id": null,
"payment_method": "cash",
"subtotal": 90.0,
"tax_amount": 15.62,
"discount_amount": 0,
"grand_total": 90.0,
"currency": "EUR",
"created_at": "2026-08-12T14:03:22Z",
"receipt_token": "b2b9c1f4…",
"receipt_number": "S-3-000142",
"amount_tendered": 100.0,
"change_due": 10.0,
"items": [
{
"sku": "WSH12",
"name": "Classic Shirt",
"qty": 2,
"price": 45.0,
"row_total": 90.0,
"tax_amount": 15.62,
"discount_amount": 0,
"base_unit_price": 49.95,
"fulfillment_mode": null
}
],
"raw_payload": "{…verbatim POSSale JSON…}",
"receipt_model": "{…ReceiptWire JSON, see below…}"
}
}
Additional optional fields cover the other sale shapes; all map 1:1 onto Hyva\Pos\Api\Data\ReceiptInterface:
sale_type(refund/exchange; omitted for regular sales),credit_memo_id,rma_id,original_order_increment_id,shipping_refund,adjustment_positive,adjustment_negative- refund and exchange receipts.layaway_kind(deposit/top_up/settlement),layaway_amount_captured,layaway_total_paid,layaway_balance_owed,layaway_expires_at- layaway receipts.split_payments,ship_to_shipments,backorder_shipments,applied_credits- JSON-encoded strings (arrays serialized by the client) for split tender, mixed fulfillment, and applied store credit / gift cards.terminal_provider,terminal_payment_id,terminal_dashboard_url,terminal_info,terminal_raw_payload- card-terminal payment fidelity (for example the verbatim Mollie response), used to reprint terminal slips and link to the PSP dashboard.is_reverse_charge,points_earned,customer_name,customer_email.
The response is the saved receipt in snake_case, including the server-assigned receipt_id and, if the client sent none, a server-generated 64-character hex receipt_token.
Idempotency
The save is keyed by the client-generated sale id: sale_id is copied into pos_order_id when the latter is unset, and a second POST carrying an already-known pos_order_id does not create a duplicate. It returns the existing row and applies at most two updates: a newer receipt_token, and a back-fill of order_increment_id when the retry arrives after order placement. Retries are safe.
The Receipt Model Bridge
receipt_model carries the post-builder receipt document - the exact model the app's renderers used to print the paper receipt, serialized as schema-versioned JSON (ReceiptWire, currently "schema_version": 1, snake_case keys, ISO-8601 dates). The server-side receipt page, PDF, and email render from this document, so the online copy matches the printed one section for section instead of being re-derived from the lossy structured columns.
Sections: header, items, totals (with tax_breakdown), payment (split payments, terminal fields), ship_to_shipments, backorder_shipments, layaway, layaway_payment, refund, fiscal, points_earned, currency_code. Store chrome (store name, logo, header/footer text) is deliberately excluded - the server renders it from the location's Magento config so admin edits apply to already-synced receipts.
The server refuses schema versions it does not know and falls back to column-driven rendering, so older module versions degrade gracefully. raw_payload is a different field with a different job: the verbatim POSSale upload JSON, stored so another device can decode it back into a sale and reprint a byte-identical receipt.
Receipt-Order Linking via pos_sale_id
Because the receipt syncs before the order exists, the receipt row first lands without an order_increment_id. The link is closed server-side:
- The receipt row stores the POS sale UUID in
hyva_pos_receipt.pos_order_id. - The order is placed via the stock
POST /V1/orderscarryingextension_attributes.pos_sale_idwith the same UUID, persisted tosales_order.pos_sale_id. - On order save, a plugin (
LinkReceiptToOrderPlugin) matchespos_order_id=pos_sale_idand back-fillsorder_increment_idon the receipt.
The linker is idempotent and never throws - a linking failure is logged but cannot break order placement. Orders without a pos_sale_id (regular storefront orders) are skipped without a query.
Fetch and List
GET /V1/pos/receipts/:receiptId returns one receipt with its items. Unknown id: 404 with The POS receipt with id "%1" does not exist.
GET /V1/pos/receipts/by-increment-id/:incrementId looks up by Magento order increment id (for example 000000042) and backs the cross-terminal restore flow: a different device fetches raw_payload verbatim and renders the original receipt locally. When historical duplicates exist, the newest row wins. Unknown increment id: 404.
GET /V1/pos/receipts?searchCriteria[pageSize]=20 uses the standard Magento searchCriteria syntax and returns the usual items / search_criteria / total_count envelope. Useful filter fields: location_id, terminal_id, created_at, sale_type, order_increment_id.
Email a Receipt
POST /V1/pos/receipts/email
Called from the app when the cashier emails a receipt to a customer-typed address. The receipt is resolved server-side by its token - the same opaque id encoded in the QR code on the register receipt - and the customer receives the QR-receipt-page rendering with a matching PDF attached. There is no per-call HTML or image payload.
Returns true on success. Failures throw a LocalizedException (400) with Failed to send receipt email: %1, and an unknown token surfaces the underlying lookup error. The REST route and the public receipt page share one rendering pipeline, so what the customer scans matches what the cashier emails.
Rate Limiting
The public receipt surfaces - the token-URL receipt page and its view / email / download actions - are rate limited to 10 requests per 10-minute window per IP + token + action combination, to prevent token enumeration and email spam. Over the limit, the request fails with Too many requests. Please wait %1 minutes. The authenticated REST routes on this page are not rate limited.
Error Behavior
Errors use the standard Magento web API envelope (message plus positional parameters):
POST /V1/pos/receiptswraps any save failure in a400Could not save the receipt: %1.- Both
GETlookups return404NoSuchEntityExceptionmessages as listed above. - A missing or invalid bearer token returns the stock Magento
401; a token lackingHyva_Pos::receiptsreturns the stock consumer-authorization error.