Appearance
Kassanet Integration
Overview
Kassanet is the underlying POS platform used by both Hendrickx and Vanhoutte POS providers. This integration handles the core communication protocol with the Kassanet API, including (optionally encrypted) data exchange, menu import, and order submission. Order submission is a three-stage flow per order: register the items on a POS "guest check" (table), retrieve the resulting bill, and record the payment tender. Hendrickx and Vanhoutte share one implementation: AbstractKassanetService provides the full functionality, and HendrickxService / VanhoutteService extend it only to set the provider value (hendrickx or vanhoutte). There is no provider-specific behaviour beyond that name.
Purpose
This integration lets you connect a location to a Hendrickx or Vanhoutte POS register to import the POS menu into Upvendo and automatically push customer orders to the POS after a customer pays. The order is registered on the POS as a guest check and the payment tender (e.g. Viva Wallet or Stripe) is recorded against that check.
Key Concepts
- Provider-Based Architecture: Kassanet is not a standalone integration page. It is accessed through either the Hendrickx (
/hendrickx) or Vanhoutte (/vanhoutte) integration. The{provider}URL segment (hendrickxorvanhoutte) selects which service handles the request. - Blowfish Encryption (default on): API communication uses Blowfish CBC encryption (zero IV, space padding) with a shared encryption token, controlled by the
enable_encryptionflag which defaults totrue. When enabled, the request body is encrypted and aSEC1path segment is inserted into the endpoint URL; responses are decrypted. There is no toggle for this in the connect form. - HMAC Signature Authentication: Every request carries a
Signatureheader — a base64-encoded HMAC-SHA256 ofDate + bodycomputed with the integration's signature key — plusDateandrequest-idheaders. - Three-Stage Order Push: An order is pushed in stages: create (POST
CreateOrderto register items on a table), bill (GETGetBillto read the POS total), pay (POSTPayBillto record the tender). Each stage is its own job, so a failure can be requeued from the exact stage that failed. - Chained Import Pipeline: Menu import is split into a chain of sequential background jobs to avoid timeouts:
ImportKassanetMenuJob->ImportKassanetCategoriesJob->ImportKassanetProductsJob->ImportKassanetDisplayGroupsJob. Each job processes one stage and dispatches the next. - Virtual Table Numbers: For order push the integration claims a free POS table/guest-check number via
GetFreeTableNumber, verifies it is empty viaGetBill, and locks it. These are POS guest-check slots used to register online/kiosk orders; they are not Upvendo in-house "table sections" and no QR codes are generated for them. The list is stored insettings.tablesand per-number usage insettings.tables_usage.
Prerequisites
- An active Hendrickx or Vanhoutte POS register reachable from the Upvendo server, with the Kassanet API enabled.
- The POS register must have products, categories, and product selections configured (these become Upvendo items, categories, and display groups).
- A location created in Upvendo (Settings > Locations).
- A branding profile configured for the merchant — this is enforced at connect time. Payment and billing profiles are optional and can be set up later.
- The location must not already have another in-house POS channel connected (see Business Rules).
- For the order tender to be recorded, the POS register must expose the matching payment type (e.g. a
Webshoptype, andViva Wallet/Stripefor the relevant channels).
Setup Steps
Step 1: Prepare Prerequisites
- Configure branding at Settings > Brand (
/settings/brand) — required. - Optionally set up payment providers at Settings > Payments and billing at Settings > Billing.
- Create your location at Settings > Locations (
/settings/locations).
Step 2: Connect the POS
- Go to Hendrickx (
/hendrickx) or Vanhoutte (/vanhoutte). - Open the connect form and enter your Kassanet credentials:
- Connection URL (
base_url): the Kassanet API endpoint URL (validated as a URL). - Signature Key (
signature_key): HMAC-SHA256 signature key. - Encryption Token (
encryption_token): Blowfish encryption key.
- Connection URL (
- Optionally set a Daily Sync Time (
sync_time) for automatic menu sync. The value must beHH:00orHH:30(minutes are restricted to00or30). The time you enter is the local time at the location and is stored as that local time, so it does not shift when the clocks change. A caption under the field names the timezone the sync will run in. - Saving the form auto-triggers a menu sync immediately after connecting.
Step 3: Sync Menu
- Click Sync Now to trigger a menu import (also runs automatically right after connecting).
- The system dispatches
ImportKassanetMenuJob, which runs the chained import:- Categories and subcategories first (
GetAllProducts). - Products and modifiers next.
- Display groups (
GetProductSelection), then stock levels (GetCountdownPluState), placeholder cleanup, a menu reload, and a final connection test.
- Categories and subcategories first (
- After completion, the menu appears under Menus > Items (
/menus/items).
Step 4: Test the Connection
- Click Test Connection on the integration page.
- The system calls the Kassanet
testendpoint and readsIsSuccessStatusCode. - On success it also resolves/caches the
Webshoppayment type, recordslast_successful_test, and reads the tip-calculator flag fromSystemInfo. - If the location was previously disconnected, a successful test marks it connected, enables a 15-minute "bulk requeue" window, and reprocesses any orders flagged
need_requeue.
Data Sync Details
What Syncs
| Kassanet Entity | Upvendo Entity | Notes |
|---|---|---|
| Categories / SubCategories | Categories | GetAllProducts; parent categories + nested subcategories. |
| Products | Items | Including price, kitchen name, and external_ids.{provider}. |
| Product Modifiers | Modifier Groups + Modifiers | Modifier groups merged after products. |
| Product Selections | Display Groups | GetProductSelection; selections map to display groups, items batch-assigned. |
| Countdown PLUs | Inventory | GetCountdownPluState stock levels synced after display groups. |
| Table Numbers | settings.tables | TableNumbers; virtual POS guest-check slots for order push (not in-house table sections, no QR codes). |
Sync Direction
- Menu Import (Kassanet -> Upvendo): One-way. Categories, products, modifiers, display groups, stock levels, and table numbers are pulled from the Kassanet API.
- Order Push (Upvendo -> Kassanet): One-way, three stages —
CreateOrder(register items) ->GetBill(read total) ->PayBill(record tender). The create stage runs increateOrder()(dispatched viaProcessOrderJob); bill and pay run inGetKassanetBillJobandPayKassanetBillJob, both on thepayments-mediumqueue. - Auto-Sync: If
sync_timeis set, a scheduler runs a full menu import daily at that time.sync_timeis stored as a local wall-clock time (not UTC) together with the resolved IANA zone insync_timezone; the scheduler converts the pair to the next absolute instant and managesnext_run_time. Because the stored value is local, the sync keeps firing at the same local time across daylight-saving transitions.
Conflict Resolution
- During import, existing records are matched by
external_ids.{provider}(items/modifiers/modifier groups) andexternal_data.{provider}(categories/display groups). Matches are updated; new entities are created. - Modifier group/item ID links are reset before product import (
resetKassanetIds) so the mapping is rebuilt cleanly each run. - Placeholder items are removed after import: items/modifiers whose
kitchen_nameiszz, or matches a* pager *pattern (case-insensitive), or whose kitchen name is a numeric value equal to the default name.
Actions
All routes are under /api/back-office/kassanet/{provider}/{locationId} (note the hyphen in back-office), with {provider} being hendrickx or vanhoutte.
Connect
- Initiate / update:
POST /api/back-office/kassanet/{provider}/{locationId}— saves credentials (base_url,signature_key,encryption_token) andsync_time(plus an optionalsync_timezone, which the back-office does not send). Re-posting on an existing integration updates it in place (no duplicate). Connecting auto-triggers a menu sync. There is no separate auto-sync-schedule endpoint; the sync time travels with this same save.
Sync / Status
- Sync Menu:
POST /api/back-office/kassanet/{provider}/{locationId}/sync-menu— runs the chained import. - Test Connection:
GET /api/back-office/kassanet/{provider}/{locationId}/test— tests connectivity, updates connection status, and (on success) requeues stuck orders. - Get Status:
GET /api/back-office/kassanet/{provider}/{locationId}/— returns credentials, sync status/logs/stats, tables, the sync time verbatim (it is already a local wall-clock, so no conversion is applied) plus itssync_timezone, andpayment_method_statusbanner flags.
Disconnect
- Delete is disabled.
DELETE /api/back-office/kassanet/{provider}/{locationId}always returns HTTP 400 with "Disconnecting {Provider} integration is disabled. Please contact administrator." The underlying cleanup routine (which would remove categories, display groups, items, menus, modifiers, inventory linked to the provider and fire aReloadMenuevent) exists in code but is not reachable from the API.
Order Flow
createOrder()(create stage): After payment is confirmed, assigns a free table number, reconciles against any prior attempt to avoid duplicate registration, then POSTsCreateOrderwith the line items, an order-type PLU marker (for KDS), and order info text. On success it dispatchesGetKassanetBillJob. Deferred payment methods (Cash on Delivery / Invoice) skip the bill+pay stages — the order stays open/unpaid on the POS.GetKassanetBillJob(bill stage): CallssaveKassanetBill()which readsGetBill, compares the POS bill total to the transaction total (logs a mismatch; a €0 bill against a non-zero transaction marks the order unrecoverable — the table was likely cleared on the POS), then dispatchesPayKassanetBillJob. Runs on thepayments-mediumqueue.PayKassanetBillJob(pay stage): CallspayKassanetBill()->PayBill, recording the payment tender (Viva Wallet for kiosk, Stripe for QR/online ordering, falling back to the Webshop type) and the tip amount. On success the transaction'sexternal_data.{provider}.statusis set tosuccessandneed_requeuecleared. Runs on thepayments-mediumqueue.
Unrecoverable Orders
Besides success and error, a transaction can land in a third, terminal state: external_data.{provider}.status = 'unrecoverable', with need_requeue = false, the raw errMessage, and an unrecoverable_at timestamp. It is never retried again — the requeue path refuses an unrecoverable transaction outright — and needs manual review.
An order is marked unrecoverable when:
GetBillreturns a €0 total for a non-zero transaction (the POS table was cleared).- A retry finds a prior
CreateOrderattempt left a non-zero bill that does not match the transaction total (partial/dirty guest check) — re-sending would compound the mess. - The create or pay stage fails with a terminal POS error: a table error such as "Tafel is leeg" / "table does not exist" / "invalid table number", or a permanent POS error code (e.g. 44 PLU not found, 77 price overwrite not allowed, 262 no booking possible/sold out, 312/321/341, PLU-filter errors).
Each one raises a [POS UNRECOVERABLE] Transaction requires manual review log entry (routed to Slack) and adds an [UNRECOVERABLE] Order #... line to the integration logs. For POS error 77 the log also carries a remediation hint: it is a POS-side fix — check the PLU price-override settings on the POS (often the order-type marker PLUs 4001/4002/4003) or update VPOS firmware to 6.3.8.0 or later.
Recovery and Observability
- Stuck Transactions screen (
/stuck-transactions): a back-office list of POS transactions that never completed, backed byGET /api/back-office/global/stuck-transactionsand gated by theVIEW_INTEGRATION_MONITORINGpermission. Per row it offers retry sync, resolve and mark success. php artisan kassanet:reconcile: a read-only report of Kassanet orders that reached the POS carrying only the order-type PLU marker and no priced lines. Each order is classifiedSELF_HEALED(the POS bill already matches, do not replay),AMBIGUOUS(a non-zero bill that does not match),NEEDS_ITEMS(no item records to replay) orREPLAYABLE. Options:--since,--merchant,--provider,--csv. It never repairs anything — it produces the list to confirm with the merchant.php artisan pos:resolve-stuck-transaction {vendor_id}: the repair step run after that confirmation. Takes--transaction_idor--order_noand--action=inspect|mark-success|re-create(defaultinspect), with an optional--provideroverride.php artisan kassanet:backfill-stable-keys: backfillsstable_keyon already-synced Kassanet items, modifiers, modifier groups and categories (--provider,--location,--dry-run).php artisan orders:process-missing-kassanet-jobs --dry-run: legacy sweep for incomplete Kassanet transactions — deprecated in favour of the scheduledpos:retry-failed-order-syncs.
Fields
Credentials (stored in ThirdPartyIntegration.credentials)
| Field | Type | Description |
|---|---|---|
base_url | string | Kassanet API endpoint URL. |
signature_key | string | HMAC-SHA256 signature key for authentication. |
encryption_token | string | Blowfish CBC encryption key. |
enable_encryption | bool | Whether requests are encrypted. Defaults to true; not set via the connect form. |
Settings (stored in ThirdPartyIntegration.settings)
| Field | Type | Description |
|---|---|---|
sync_time | string | Daily auto-sync time as a local wall-clock (HH:00 or HH:30), not UTC. |
sync_timezone | string | IANA zone sync_time is expressed in. Defaults to the location's timezone on first write, falling back to Europe/Brussels. Read back for display; never null. |
next_run_time | string | Next scheduled sync run, as an absolute UTC instant (managed by the scheduler). |
tables | array | Table/guest-check numbers retrieved from TableNumbers. |
tables_usage | object | Per-table usage map (last_used, transaction_id). |
webshop_payment_type | object | Cached Webshop payment type ({Number, Name}) from the POS. |
cod_payment_method | string | Cash-on-Delivery payment type name to resolve on the POS (default cash). |
Status / sync fields (on ThirdPartyIntegration)
| Field | Type | Description |
|---|---|---|
sync_status.status | string | success, error, or in_progress. |
sync_status.message | string | Human-readable sync message. |
sync_status.details | object | Holds display_groups_imported, menu_id. |
sync_status.last_sync | string | ISO 8601 timestamp of the last import. |
sync_status.stats | object | productsCount, ordersCount (total/queued/failed/success), successRate, lastSync. |
sync_status.logs | array | Recent log entries (info/success/error), capped. |
sync_status.connection_status | object | {status, text, lastChecked}. |
last_successful_test | string | ISO 8601 timestamp of the last successful test. |
Kassanet API endpoints called
| Endpoint | Method | Description |
|---|---|---|
test | GET | Test connectivity / read SystemInfo. |
GetAllProducts | GET | Fetch all categories, subcategories, products. |
GetProductSelection | GET | Fetch product-selection (display group) definitions. |
GetCountdownPluState | GET | Fetch stock levels (countdown PLUs). |
TableNumbers | GET | Fetch available table/guest-check numbers. |
GetFreeTableNumber | GET | Get the next free table number for an order. |
GetBill?tableNumber= | GET | Retrieve the bill for a table number. |
GetPaymentTypes | GET | Fetch available payment types. |
CreateOrder | POST | Register order line items on a table. |
PayBill | POST | Record payment tender against the bill. |
PostDeposit | POST | Record a deposit/partial payment (used by split-payment tooling). |
The exact request/response payloads and the full table-assignment and modifier-merge internals are not fully verified here.
Business Rules
- A location may have only one in-house POS channel. Connecting Hendrickx is blocked if Vanhoutte (or another in-house POS such as ShopCaisse/MplusKassa) is already connected for that location/merchant, and vice versa — you cannot have both on the same location.
- Connecting requires a branding profile; payment and billing profiles are optional.
- The import chain jobs use 3 retry attempts with a 30-second backoff; per-job timeout is 300 seconds, except the products job which allows 600 seconds.
- Order-push table assignment uses MongoDB-based distributed locking (per location + table) with a 300-second (5-minute) lock timeout to prevent two orders claiming the same table; up to 50 attempts are made to find a free table.
- API requests use exponential backoff for retryable errors (timeouts, connection failures, HTTP 500/502/503/504/408). A non-retryable or final failure marks the location disconnected.
- When a request fails because the POS is unreachable, the location is marked "disconnected" and affected orders are flagged
need_requeue. A successfultest(manual, via thekassanet:check-and-requeuecommand, or scheduled) marks the location connected and requeues those orders from their last incomplete stage. - A bill-total vs transaction-total mismatch is logged as a critical error but does not block payment; a €0 bill against a non-zero transaction marks the order unrecoverable.
- An order marked
unrecoverableis terminal: retries are refused, it does not requeue on reconnect, and it must be resolved manually. - Scheduled recovery tasks:
kassanet:check-and-requeueandpos:retry-failed-order-syncsrun every 5 minutes, andpos:reconcile-stuck-transactionsruns hourly (plus a daily--prunepass at 03:00).
FAQs
What is the difference between Kassanet, Hendrickx, and Vanhoutte?
Kassanet is the underlying POS platform/API. Hendrickx and Vanhoutte are the two POS fascia that run on it. In Upvendo you connect via the Hendrickx or Vanhoutte page; both use the identical Kassanet protocol and the same code, differing only by the {provider} name.
Can I create menu items in Upvendo and push them to Kassanet?
No. Menu sync is one-way from Kassanet to Upvendo. Items must exist in the POS register first; you can then customize descriptions, images, and display order in Upvendo.
Does Kassanet process the customer's payment?
No — Upvendo collects the money via Viva Wallet (kiosk) or Stripe (online/QR ordering). Kassanet's role at the pay stage is to record the payment tender on the POS bill (via PayBill) so the POS shows the order as settled. The POS must have the matching payment type configured.
What happens when the POS register goes offline?
The order's transaction is flagged external_data.{provider}.need_requeue = true and the location is marked disconnected. When connectivity is restored (detected via the test endpoint), flagged orders are automatically reprocessed from the stage that failed (create, bill, or pay).
How does auto-sync work?
If you set a daily sync time (HH:00 or HH:30), a full menu import runs at that time every day. The time is your location's local time and is stored as such, so it stays put across daylight-saving changes. The scheduler manages next_run_time.
Why are some items missing after sync?
Placeholder items are removed after import: kitchen_name of zz, a * pager * pattern, or a numeric kitchen name equal to the default name. Also confirm items are assigned to a product selection (display group) on the POS.
Can I disconnect a Hendrickx/Vanhoutte integration myself?
No. The disconnect endpoint is intentionally disabled and returns an error asking you to contact the administrator.
Troubleshooting
"Connection test failed" or "Disconnected" status
Verify the Connection URL is correct and reachable, and that the Signature Key and Encryption Token match the POS register configuration. Ensure the POS register is powered on and online.
"Invalid base64 input" or "Blowfish decryption error"
The Encryption Token is incorrect or has changed, or enable_encryption does not match the POS configuration. Update the credentials with the correct token.
Menu sync stuck at "in_progress"
The import chain may have failed at a stage. Check the integration logs on the Hendrickx/Vanhoutte page. Each job retries 3 times (30s backoff); timeouts are 300s (600s for products). If it keeps failing, check the POS API responsiveness.
"Total amount mismatch" in logs
The GetBill total from Kassanet does not match the transaction total in Upvendo. This is logged as a critical error but does not block payment. Check that POS prices match Upvendo prices and look for tax/rounding differences. A €0 bill against a non-zero transaction marks the order unrecoverable (the POS table was likely cleared).
Orders not appearing / not settled in the POS
Check the connection status (should be "connected"). Inspect external_data.{provider} on the transaction — status is success, error or unrecoverable; the errMessage, need_requeue flag, and which of create / bill / pay is missing tell you the failed stage. If the POS was offline, error orders requeue automatically once a test succeeds; otherwise use the back-office retry-sync action. An unrecoverable order will not requeue — not on reconnect, not via retry-sync — and needs manual review (look for the [POS UNRECOVERABLE] log entry and the [UNRECOVERABLE] Order #... line in the integration logs). For POS error 77 the fix is on the POS: PLU price-override settings or VPOS firmware 6.3.8.0+.
Assistant Guidance
When a user asks about Kassanet, Hendrickx, or Vanhoutte integration:
- Determine which provider they use (Hendrickx or Vanhoutte) — the behaviour is identical, only the name differs.
- For connection issues, ask for the Connection URL and whether the POS register is online, and suggest a test connection.
- For menu sync issues, check the sync status and logs on the integration page; the import runs as chained background jobs and can take a few minutes.
- For order issues, check the connection status and the transaction's
external_data.{provider}(status,errMessage,need_requeue, and which ofcreate/bill/paycompleted). - Clarify that Upvendo collects the payment (Viva Wallet for kiosk, Stripe for online/QR); Kassanet records the tender on the POS bill.
- Disconnecting is disabled in the UI/API — direct users to contact an administrator.
- Table numbers are virtual POS guest-check slots used for order push (with 5-minute distributed locks), not Upvendo in-house table sections.
- The CLI command
php artisan app:import-kassanet {locationId} --provider={hendrickx|vanhoutte}triggers a manual import (it dispatches the category import job directly). - For stuck orders, the back-office retry-sync re-dispatches the missing stage; the
kassanet:check-and-requeueconsole command requeues across reconnected locations. The Stuck Transactions screen (/stuck-transactions, permissionVIEW_INTEGRATION_MONITORING) lists them with retry-sync / resolve / mark-success actions. - For a suspected marker-only push (order on the POS with no priced lines), run
kassanet:reconcilefirst — it only reports (SELF_HEALED/AMBIGUOUS/NEEDS_ITEMS/REPLAYABLE) and the verdict has to be confirmed with the merchant before repairing withpos:resolve-stuck-transaction. Never replay blindly: staff may already have keyed the items into the POS by hand. - If a transaction's status is
unrecoverable, stop suggesting retries — it will not requeue. It needs manual review, and for POS error 77 the fix is on the POS side (PLU price-override settings / VPOS firmware 6.3.8.0+).
Relations
Depends On
- Locations (a location must exist before connecting).
- Branding profile (required at connect time).
- Payment providers (Viva Wallet / Stripe collect the money; Kassanet records the tender).
- ThirdPartyIntegration model (stores credentials, settings, sync status, logs).
Affects
- Menu Items (created/updated during import).
- Categories (created/updated during import).
- Display Groups (created from product selections).
- Modifier Groups and Modifiers (created from POS modifier data).
- Inventory (stock levels synced from countdown PLUs).
- Transactions (orders pushed to the POS via the create/bill/pay jobs;
external_data.{provider}holds the stage results and status).