Customers and Pricing
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 |
|---|---|---|
POST |
/V1/pos/customer-validate |
Pre-validate a draft customer through the validator pool |
POST |
/V1/pos/customers |
Create a customer (re-validated at the boundary) |
POST |
/V1/pos/self-checkout/otp/request |
Email a one-time sign-in code to a customer |
POST |
/V1/pos/self-checkout/otp/verify |
Verify the code, return a customer summary |
POST |
/V1/pos/customer-prices |
Customer-specific prices from the pricing-resolver pool |
GET |
/V1/pos/customers/:customerId/credit-limit |
Available credit for pay-by-invoice |
GET |
/V1/pos/wishlist/:customerId |
The customer's wishlist, shown on the customer profile at the register |
GET |
/V1/pos/customers/:customerId/rule-usage/:ruleId |
How often the customer used a cart price rule and one of its coupons |
GET |
/V1/pos/catalog-rule-prices |
The day's catalog rule prices of a website, every customer group, for the offline cache |
GET |
/V1/pos/fixed-product-taxes |
Every fixed product tax row of a website, for the offline cache |
GET |
/V1/pos/newsletter/status |
A customer email's newsletter subscription state |
POST |
/V1/pos/newsletter/subscribe · /unsubscribe |
Subscribe or unsubscribe an email |
GET |
/V1/pos/customer-insights/:customerId |
Buying-behaviour statistics and custom insight sections for the Customer Insights sheet |
All routes require the Hyva_Pos::locations ACL resource on the calling token, except the credit-limit, newsletter and customer-insights routes, which ride Magento's standard Magento_Customer::manage resource: any token that can already manage customer data can call them.
Validate a Draft Customer
POST /V1/pos/customer-validate: runs a draft customer payload through the server-side validator pool before anything is saved. Called by the POS customer-create sheet on Save, so validation rules (email uniqueness, taxvat format, vendor postcode/VAT lookups) live in Magento configuration instead of being duplicated on the device. Vendor modules add validators by implementing CustomerValidatorInterface and registering with the CustomerValidatorPool via di.xml; built-in validators run first, vendor validators refine after.
Request: the payload is wrapped in a request key. Only email, firstname, lastname, and websiteId are required; omit optional keys instead of sending null. websiteId is the website the customer belongs to: the register sends the website of the store view it sells under. locationId (module 1.2.0, optional) is the register's own location, and it decides which customer validators run when that location overrides the global selection. A location that does not exist, or that belongs to another website, is refused with an input error naming the field rather than validated under a different policy. Zero means unspecified on either side: a request without websiteId, and a location the merchant never scoped to a website, both pass the membership check, while two websites that are stated and different fail it. Omit locationId, as a register older than 1.2.0 does, and the website setting applies. The create endpoint reads it the same way, so a customer saved without a prior validate call is checked under the same policy. Send 0 when the caller does not know it and the server resolves it: the website of the store the call runs in, or the default store view's website when the call runs in the all scope, as a token-authenticated REST call does. 0 is never saved as a website. Include customerId only on an edit flow, so the email-uniqueness check excludes the customer's own record:
{
"request": {
"email": "jane@example.com",
"firstname": "Jane",
"lastname": "Doe",
"websiteId": 1,
"company": "Acme BV",
"taxvat": "NL123456789B01",
"phone": "+31 6 12345678",
"street": "Keizersgracht 123",
"city": "Amsterdam",
"postcode": "1015CJ",
"countryCode": "NL"
}
}
Response: always 200 with a structured result; only network, auth, and server failures produce an error status. valid is true only when no row has severity error. Severities are error (blocks Save), warning, info, and normalize (carries a suggested_value the UI offers as a one-tap fix):
{
"valid": false,
"errors": [
{
"field": "postcode",
"code": "postcode_normalized",
"severity": "normalize",
"message": "Postcode formatted for NL.",
"suggested_value": "1015 CJ"
}
]
}
Create a Customer
POST /V1/pos/customers: defensive create wrapper around Magento's CustomerRepositoryInterface::save(). It re-runs the same validator pool at the create boundary, so a client that skips the pre-validate call still cannot land invalid data. Only severity-error rows block; warnings and info rows pass through because the cashier already saw them in the form.
Request: identical shape to /V1/pos/customer-validate, the same request object. The account is saved on the request's websiteId, resolved the same way, so a customer created at the register belongs to the same website as the store's other customers and the uniqueness check reads the website the save will use.
Response: 200 in both outcomes. On success, customer_id carries the new entity id. On validation failure, success is false and errors carries the same severity-tagged rows the validate endpoint returns, so the client can attach them per field without a second round-trip:
Errors: an email that already exists surfaces as a validation row, not an HTTP error. A 400 indicates a malformed payload; 401 a bad token.
Customer Sign-In via One-Time Code
The self-checkout kiosk signs a customer in by email plus a one-time code. Both calls authenticate with the terminal's own token; no customer token is ever minted; downstream credit and loyalty lookups reuse the terminal's integration auth.
Request a Code
POST /V1/pos/self-checkout/otp/request: generates a code, stores a hash, and emails it to the customer.
Response: 200 with an empty body. The response is identical whether or not the email belongs to an account, so the kiosk cannot be used to enumerate registered customers. Rate-limit hits and mail-transport failures return 400 with a message envelope.
Verify a Code
POST /V1/pos/self-checkout/otp/verify: verifies the code for the email + terminal pair and marks it consumed so it cannot be replayed.
Response: an unprivileged customer summary, enough to attach the customer to the cart:
{
"customer_id": 1287,
"email": "jane@example.com",
"firstname": "Jane",
"lastname": "Doe",
"group_id": 1,
"website_id": 1,
"company": null,
"taxvat": null,
"default_billing_address_id": 542
}
Errors: 401 for an invalid, expired, or exhausted code; 404 when no code exists for the email or the terminal does not match the one that requested it.
Customer Prices
POST /V1/pos/customer-prices: resolves per-customer prices for a batch of SKUs. The server walks the CustomerPriceResolverPool (customer-group prices, Adobe Commerce B2B company tiers, vendor contract prices); the first non-null result per SKU wins. Vendor modules plug in by implementing CustomerPriceResolverInterface.
When the register calls this endpoint is a merchant setting: Stores → Configuration → Hyvä POS → Terminal Defaults → Customer Prices, overridable per location on the location form and lockable under POS Lock. In When added to cart mode the register resolves each product as the cashier rings it up, one small batch per add. In While browsing the catalog mode it also resolves the SKUs of every catalog page the cashier views, typically 20 to 60 SKUs per request, so discounts show on the product tiles before anything is added. The API itself puts no ceiling on batch size; size a custom resolver for the browse batch, not the single-SKU case.
Request: omit storeView entirely when you do not need it. Sending "storeView": null explicitly trips Magento's web API input processor on the nullable string type and the whole call returns 400. websiteId: 0 means the customer's default website:
Response: one row per SKU the pool had an opinion on; SKUs without a resolved customer price are absent and the POS displays the base price. source is a stable token naming the resolver that produced the value. tiers is an optional qty-threshold ladder ("at qty N or higher, the customer pays this"); customer_price stays the qty-1 price:
{
"prices": [
{
"sku": "24-MB01",
"base_price": 45.0,
"customer_price": 38.25,
"currency": "EUR",
"source": "customer_group",
"tiers": [
{ "qty": 10.0, "price": 36.0 }
]
}
]
}
Rule Usage per Customer
GET /V1/pos/customers/:customerId/rule-usage/:ruleId?couponCode=SPRING10: how often this customer has used the cart price rule, and the given coupon code of that rule. The register evaluates promotion rules itself and reads these two counts before it accepts a coupon for an attached customer whose rule sets Uses per Customer (or whose coupon sets a per-customer usage limit); when a limit is reached the code is refused at the counter with the reason. Without the module the register applies such a coupon and tells the cashier the limit was not checked. couponCode is optional; leave it out for an automatic (no-code) rule. Read-only, two indexed lookups. An unknown customer, rule or code answers zero.
{
"customer_id": 1287,
"rule_id": 12,
"rule_times_used": 1,
"coupon_code": "SPRING10",
"coupon_times_used": 1
}
Catalog Rule Prices for the Offline Cache
GET /V1/pos/catalog-rule-prices?websiteId=1: the catalog rule prices of the website for a day, every customer group, from Magento's catalogrule_product_price index: {"indexed_at": "2026-09-09 18:44:43", "date": "2026-09-10", "unchanged": false, "items": [{"product_id": 68, "customer_group_id": 0, "rule_price": 765}, ...]}. date (Y-m-d) defaults to today in the store's timezone. Pass an earlier answer's indexed_at as ifIndexedAfter and the answer is unchanged: true with no items when the catalog rule indexers have not run since. The register requests the full set again when its cached day is over, since the index holds rows per day. A PRO register refreshes this with its offline catalog and, offline, prices a line's catalog rule from it for the cart's customer group. Read-only.
GET /V1/pos/fixed-product-taxes?websiteId=1: every fixed product tax (FPT, WEEE) row of the website's products, from Magento's weee_tax table: {"generated_at": "2026-09-13T11:24:05+02:00", "rows": [{"sku": "bowl-brass", "attribute_code": "recycling_fee", "website_id": 0, "country_id": "NL", "region_id": 0, "value": 0.05}, ...]}. Rows with website_id 0 apply to every website; region_id 0 means the whole country; value is per unit in the base currency, excluding tax. Only products with rows appear, so a product absent from the answer charges no fee; rows is empty when the store charges none. Magento's product list never returns an FPT attribute (its backend is a table of its own that the product collection does not load), so this is how a PRO register learns the fee table for its offline catalog; without it the register reads the single product on the first add of a SKU. Module 1.2.0 and later. Read-only.
Credit Limit
GET /V1/pos/customers/:customerId/credit-limit: resolves the pay-by-invoice credit limit for a customer. The POS calls it at checkout when a cart is about to settle via the pos_invoice payment method, and again from the per-customer Open Invoices sheet. Optional query parameter websiteId; omitted or 0 resolves the current request's website. The server walks the credit-limit resolver pool first-write-wins; used is computed live from the customer's OPEN (issued but unpaid) invoices.
{
"customer_id": 1287,
"limit": 5000.0,
"used": 1240.5,
"available": 3759.5,
"currency": "EUR",
"source": "hyva_pos"
}
limit: 0 means "no credit extended": render it as pay-by-invoice being unavailable, not as "limit reached". available is precomputed max(0, limit - used). With Use Customer Credit Limits on, the register offers pay-by-invoice only to a customer with limit above 0. Going over available is a warning, not a block: the register marks the cart as exceeding the credit limit, and a cashier whose role has the pay-by-invoice permission can still place the order. No setting turns the warning into a hard block. Errors: 404 for an unknown customer id.
Newsletter Subscription
The register's newsletter surfaces (the customer-detail toggle, the receipt hand-over and email dialogs, the customer display) run on three endpoints:
GET /rest/V1/pos/newsletter/status?email=… — Current state for an email
POST /rest/V1/pos/newsletter/subscribe — { email, customerId }
POST /rest/V1/pos/newsletter/unsubscribe — { email, customerId }
All three return the same shape:
status is one of subscribed, not_active, unconfirmed, unsubscribed, or none (no subscriber record). Double-opt-in stores answer a subscribe with unconfirmed; render the truth, not the tapped intent.
The validation and routing replicate Magento's GraphQL subscribeEmailToNewsletter mutation without depending on the GraphQL modules: email format, the foreign-account guard, and the store's Allow Guest Subscription setting for emails that don't belong to an account. A customerId whose account email matches links the subscription to the account; anything else follows the guest path. Both directions are idempotent: subscribing an already-subscribed email (or unsubscribing a non-subscriber) returns the current state instead of erroring, so register toggles can't fail on a double tap.