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 |
All routes require the Hyva_Pos::locations ACL resource on the calling token, except the credit-limit route, which rides Magento's standard Magento_Customer::manage resource - any token that can already read customer data can read the credit limit.
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. 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.
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 - 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 simply 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 }
]
}
]
}
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). Going over the limit is a warning an authorized cashier can override per transaction unless the merchant opted into a hard block via location config. 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.