Source: https://wpinplate.com/en/docs/technical-information/

# Technical information for developers

This chapter is for agencies, freelancers and administrators who deploy WP InPlate on a client's website.

## Compatibility

- WordPress 6.4+, PHP 8.1+, WooCommerce 8.0+ (tested up to 11.2).
- Compatibility with **HPOS** (WooCommerce High-Performance Order Storage) is declared.
- The elements on the website are Gutenberg blocks and shortcodes – they work in any WordPress-compatible theme.
- A custom login address (e.g. WPS Hide Login) is supported by the start screen and the team apps.

## App addresses

WP InPlate registers its own addresses (WordPress rewrite rules):

| Address | App |
|---|---|
| `/wp-inplate/start/` | Team start screen |
| `/wp-inplate/kds/` | KDS |
| `/wp-inplate/courier/` | Courier app |
| `/wp-inplate/radio/` | Radio |
| `/wp-inplate/tv/` | TV launcher |
| `/wp-inplate/tv/{slug}/` | TV screen |
| `/wp-inplate/tv-manager/` | TV Manager |
| `/wp-inplate/table/{token}/` | The link from a table's QR code (a redirect to the table page) |

Every installable app has a manifest generated by the plugin (the name from the website's name and **Settings → System → Phone and tablet apps**) and a service worker:

| Address | File |
|---|---|
| `/wp-inplate/kds-manifest.webmanifest`, `/wp-inplate/kds-sw.js` | KDS manifest and service worker |
| `/wp-inplate/courier-manifest.webmanifest`, `/wp-inplate/courier-sw.js` | Courier app manifest and service worker |
| `/wp-inplate/radio-manifest.webmanifest`, `/wp-inplate/radio-sw.js` | Radio manifest and service worker |

All addresses are relative to the website's address, so WordPress in a subdirectory (e.g. `example.com/shop/`) works the same: the KDS is at `/shop/wp-inplate/kds/`. The server must not serve `.webmanifest` and `.js` files under `/wp-inplate/` as static files – these requests must reach WordPress.

These addresses need pretty permalinks. With the “Plain” format (`?p=123`) they return a 404 error – [Readiness to sell](/en/docs/setup-wizard/#readiness-to-sell) shows it as a blocker with a **Turn on pretty permalinks** button (the “Post name” format). When the stored rules don't contain InPlate's (e.g. another plugin rebuilt them before they were added), InPlate rebuilds them itself on the next request. If the addresses still return a 404 error, save the permalink settings again (**Settings → Permalinks → Save Changes**).

The plugin's REST API uses the `wp-inplate/v1` namespace (`/wp-json/wp-inplate/v1/…`).

[Pausing orders](/en/docs/manager-panel/#pausing-orders) is saved in a separate option, `wp_inplate_ordering_pause`, not in the settings, so saving the settings or the schedule never undoes it. Endpoint `/wp-json/wp-inplate/v1/ordering-pause` (GET – people with KDS access, POST and DELETE – the `inplate_pause_orders` or `manage_woocommerce` capability). A pause with a time limit ends at the first read after the time has passed, without cron.

[Switched-off single times](/en/docs/schedule/#switching-off-single-times) are saved in a separate option, `wp_inplate_slot_blocks` (for each method: the `today` or `until_reset` mode, the day, the “from” time and interval they were chosen with, the list of times). Saving the schedule doesn't undo them; a “Today only” switch-off disappears at the first read after today's window ends, without cron. Endpoints (the `inplate_manage_menu` or `manage_woocommerce` capability): `GET /wp-json/wp-inplate/v1/schedule/slot-blocks`, `PUT` and `DELETE /schedule/slot-blocks/{pickup|delivery|dine_in}`. While a method has switched-off times, `POST /schedule` rejects a change of its “from” time and interval (409 `slot_blocks_active`). When an order is placed, the server checks the chosen time (the window's grid, switch-offs, whether it has passed, the day shown to the customer) and, if needed, returns 409 `slot_unavailable` with the current list of times.

## Cache

Exclude from full-page caching (cache plugins, server caches, CDNs):

- the menu page, the order page and the table ordering page,
- all `/wp-inplate/…` addresses,
- the `/wp-json/wp-inplate/…` REST API.

Caching these addresses is the most common cause of an outdated menu and of problems with the first test order.

## HTTPS and the server

- HTTPS is required to install the apps (PWA) and for push notifications.
- Push notifications require the OpenSSL PHP extension.
- Maps and geocoding use OpenStreetMap (map tiles and Nominatim) – the server and the administrator's browser must have access to `openstreetmap.org`.
- Scheduled tasks (e.g. turning on InPlate dishes at midnight, bringing back dishes turned off “for today only”) use WP-Cron. On low-traffic websites consider a system cron that calls `wp-cron.php`.

## Order statuses

| KDS status | WooCommerce status |
|---|---|
| New, In preparation, Ready | unchanged (the status is set by the payment, e.g. “Processing” or “On hold”) |
| On the way | `wc-in-transit` – a status added by WP InPlate (delivery only) |
| Delivered | `completed` |
| Cancelled | `cancelled` |

The kitchen status, fulfillment method, time, table and assignment are saved in the order data.

## Payment and shipping methods

| Identifier | Name |
|---|---|
| `wpi_cash_pickup` | InPlate \| Pay at the counter (pickup and eating in) |
| `wpi_cash_delivery` | InPlate \| Pay the courier (delivery) |
| `wpi_cash_table` | InPlate \| Pay at the table (QR table) |
| `wp_inplate_shipping` | The “InPlate – Delivery / Pickup” shipping method (the pickup address in the email) |

The server rejects an order with a payment method that doesn't match the fulfillment method.

The [packaging fee](/en/docs/settings/#packaging-fee) is a WooCommerce fee line (`WC_Order_Item_Fee`) with the `_wpi_packaging_fee` (fulfillment method) and `_wpi_packaging_gross` (gross amount) metadata. WooCommerce saves the net fee and the tax separately; WP InPlate sets the tax included in the gross amount, so the customer pays exactly the amount from the settings, and the order's VAT lines add up.

The radio connects to the Atmosphere catalog only from the website's server – the browser gets no key and reads the catalog through `/wp-json/wp-inplate/v1/radio/catalog` (plays: `POST /radio/play`, the connection state for the administrator: `/radio/status`). The website confirms its license with signed Freemius API requests of its installation; Atmosphere checks them with Freemius and issues a session token valid for up to 12 hours (stored in the `wp_inplate_atmosphere_token` transient). The catalog is cached for 10 minutes (`wp_inplate_radio_catalog`). The website must be able to make outgoing HTTPS connections to `wp-inplate-atmosphere.vercel.app`.

The radio's [My stack](/en/docs/radio/#my-stack) is the `_inplate_radio_stack` user meta – only references to the Atmosphere catalog (track, playlist and mood identifiers, up to 2,000 items), without stream addresses. The `/wp-json/wp-inplate/v1/radio/stack` endpoint (GET and PUT – the whole stack at once) is available to signed-in staff (not the store's customers) with a license that covers the radio; everyone sees and saves only their own stack.

[Shared playlists](/en/docs/radio/#shared-playlists) are saved in the author's `_inplate_radio_shared` user meta (up to 30 playlists per author, a name of up to 60 characters; the same references as the stack), so they disappear together with the author's account. Endpoints: `GET` and `POST /wp-json/wp-inplate/v1/radio/shared` (the list of all and sharing – the same name, regardless of letter case, replaces the author's playlist) and `DELETE /radio/shared/{author id}-{key}`. Access as for the stack; only the author or a user with the `manage_woocommerce` capability can delete a playlist – otherwise the server returns 403.

## Content types and tables

- TV screens are the `inplate_display` post type, edited in Gutenberg.
- Staff permissions are WordPress capabilities with the `inplate_` prefix (e.g. `inplate_kds_access`), and the staff role is `inplate_operator`.
- WP InPlate creates its own database tables: InPlate dishes, delivery zones (`inplate_zones`, with the free delivery threshold `free_from`, DB 1.19), order assignments, status history, add-on templates, cross-sell templates, variant sets (`inplate_variant_sets`), QR tables, price history (`inplate_price_history`), happy hours rules (`inplate_happy_hours`) and promotions (`inplate_promotions`, DB 1.18).
- Sizes are native WooCommerce variations: a variable product with one local attribute (the choice name from the set). WP InPlate saves the product type, the attribute and the variations only through the WooCommerce API and only after the preview is confirmed (`POST /variant-sets/{id}/plan` returns a plan with a hash, `POST /variant-sets/{id}/apply` runs it only with that hash; a changed plan – 409 `plan_changed`). The product ID doesn't change. A size without a price is a variation with the `private` status; going back to a regular product deletes the variations with the WooCommerce mechanism (`woocommerce_product_type_changed`). Variable products created in WooCommerce aren't changed. Meta: `_wpi_variant_set` (the set assigned to the product), `_wpi_variant_skip` (“No variants”), `_wpi_variant_applied` (the set whose sizes the product has), `_wpi_variant_size` (the variation's size), the category meta `wpi_variant_sets`. Add-on templates: `variant_set_id`, `size_ids`, and on items `same_price`, `size_prices` and – for a mandatory surcharge – `methods` (fulfillment methods: `pickup`, `delivery`, `dine_in`, `table`; an empty list = always; DB 1.15). Surcharges of cart items are kept separately (`wpi_surcharges`) and calculated at every cart recalculation by the fulfillment method (a QR table or the method chosen in the summary); `POST /checkout/method` also returns the items with new prices. REST: `/wp-inplate/v1/variant-sets` (CRUD, `/duplicate`, `/bulk-assign`, `/grid`, `/plan`, `/apply`), `/terms/{id}/variant-sets`, `/products/{id}/variant-set` – the `inplate_manage_menu` or `manage_woocommerce` capability, saving variations also `edit_products`. The menu API (`/menu`, `/menu/product/{slug}`) and cross-sell suggestions return the `variants` field (sizes with prices, Omnibus, availability and weight) and the cheapest size's price and `price_max`. The variation ID goes to the cart (`?wc-ajax=add_to_cart` with the variation's `product_id`); `POST /checkout/replace-cart-item` accepts `variation_id`. Add-on and cross-sell templates have `POST /{type}/{id}/duplicate`.
- Allergens: the dictionary in `Support\Allergens` – 14 built-in ones (ids `gluten`, `crustaceans`, `eggs`, `fish`, `peanuts`, `soy`, `milk`, `nuts`, `celery`, `mustard`, `sesame`, `sulphites`, `lupin`, `molluscs`, numbers 1–14) and own ones in the `wp_inplate_allergens` option (id `own-…`, numbers from 15, never reused). A dish's information is in the `_wpi_allergens` product meta (`filled`, `contains`, `may_contain`, `ingredients` with `**…**` highlights) – also for InPlate dishes (their product's meta). Add-on template items have an `allergens` column (DB 1.16), options – an `allergens` field in the options' JSON; a product's own add-ons – in `_wpi_addons`. WooCommerce fields aren't changed. An order item gets a copy of the allergens (of the dish and the chosen add-ons, with names) in the hidden `_wpi_allergens` meta; shown through `woocommerce_order_item_meta_end` (emails, thank-you page, My account) and `woocommerce_after_order_itemmeta` (wp-admin). REST: `GET /wp-inplate/v1/allergens` (public; for managers also `usage` and `missing`), `POST`, `PUT /allergens/{own-id}`, `DELETE /allergens/{own-id}` (removes the allergen from dishes and templates), `GET`/`PUT /allergens/product/{id}`, `POST /allergens/bulk` (`product_ids`, `add`, `remove`) – the `inplate_manage_menu` or `manage_woocommerce` capability. The menu API and cross-sell suggestions return `allergens` (`null` when not filled in); the menu page gets the dictionary of names in `wpInplateMenu.allergens`. Allergen chart: the `[wp-inplate-allergen-card]` shortcode, the `wp-inplate/allergen-card` block. Settings in `wp_inplate_settings`: `allergens_display` (`card_modal` – default, `modal_only`, `off`; passed to the menu in `wpInplateMenu.settings.allergens_display`; with `off` the email hook prints nothing, while wp-admin and the allergen chart keep working) and `allergens_on_labels` (default `false`; `POST /wp-inplate/v1/kds/label/{id}` then returns `allergens` for the items – `[name, value]` pairs, e.g. `["Allergens", "Gluten, Milk"]` – from the copy saved in the order).
- The price history (Omnibus) is made of fixed-price periods for every product and variation, saved when a product is saved, on a direct change of the `_price` meta, on a status change and by the daily `wp_inplate_price_history_reconcile` cron (entries older than 400 days are deleted). The first fill after an update is done by the one-off `wp_inplate_price_history_seed` cron event. Prices in WooCommerce aren't changed.
- Happy hours rules are saved in versions: a change closes the current version and creates a new one, so the lowest price in 30 days takes earlier windows into account. The switch and the history of its changes are in the `wp_inplate_happy_hours` option. In the cart the discount is a virtual WooCommerce coupon `happy-hours-{id}` (with no database entry); the order saves a copy of the rule in the `_wp_inplate_happy_hours` meta. REST: `/wp-inplate/v1/happy-hours/status` (public), `/wp-inplate/v1/manager/happy-hours` (management: `inplate_manage_menu` or `manage_woocommerce`, saving also `edit_products`), `POST /wp-inplate/v1/checkout/method` (the fulfillment method chosen in the summary).
- Promotions (`inplate_promotions`): the type (`order_discount`, `free_delivery`, `buy_x_get_y`, `gift`) with its configuration in JSON, days, hours, dates, channels, `combine_coupons`, the order. Product and order discounts are virtual WooCommerce coupons `promo-{id}` (with no database entry), calculated on every cart recalculation; free delivery lowers the InPlate delivery fee. The order saves a copy of the promotions in the `_wp_inplate_promotions` meta, and a €0 delivery line saves the fee before the promotion (`_wpi_delivery_fee_original`) and its name (`_wpi_delivery_promotion`). `POST /wp-inplate/v1/checkout/place` takes `automatic` (the automatic discounts shown to the customer) and `delivery_fee`; a difference = 409 `promotions_changed`. A zone's free delivery (`free_from`, only with a zone fee above 0) counts the products after discounts and gives the same 0 delivery line (named “Darmowa dostawa od 199 zł” in the base language); with a free delivery promotion the lower fee wins. REST: `/wp-inplate/v1/promotions/status` (public), `/wp-inplate/v1/manager/promotions` (permissions as for happy hours, license `can_manage_promotions`).

## Customer data

- **Sign-in codes** – the database holds only their hash (HMAC), never the code itself. They expire after 10 minutes and work once.
- **Consents** – on the account (the `_inplate_consents` user meta) and on the order (the `_inplate_consents` order meta): identifier, version, text, date, source.
- **Delivery addresses** – the `_inplate_addresses` user meta (up to 10 addresses: name, city, street, number, flat, notes for the courier, coordinates) and `_inplate_default_address`. The WooCommerce billing and shipping addresses (**My account → Addresses**) aren't changed – they are the customer's data, e.g. for invoices. Every order has its own delivery address. Coordinates are set once when the address is saved (OpenStreetMap), the zones – for every order.
- **VAT number** – one field added to the WooCommerce billing address (**My account → Addresses**), the `billing_inplate_nip` user meta. Apart from that the address forms are fully WooCommerce – InPlate doesn't hide or change anything in them.
- **Export and erasure** – **Tools → Export Personal Data / Erase Personal Data** cover the consents and delivery addresses saved on the account.

## The look of “My account”

When the **InPlate look for “My Account”** is on in **Settings → Customer accounts**, the WooCommerce **My account** pages get the `wpi-account` class on `<body>` and the `account-look.css` stylesheet. The rules apply only on the account pages and win over the theme, so the look is the same on every website.

You change colors, corner radii and spacing with CSS variables – without fighting specificity. Put them in the theme's stylesheet or in **Appearance → Customize → Additional CSS**:

```css
body.wpi-account {
	--wpi-account-accent: #e63946;      /* buttons, active menu item */
	--wpi-account-accent-hover: #c1121f;
	--wpi-account-radius: 4px;
	--wpi-account-font: "Inter", sans-serif;
}
```

| Variable | Default | Used for |
|---|---|---|
| `--wpi-account-accent` | `--wpif-primary` | Buttons, the active menu item, the border of a field being edited |
| `--wpi-account-accent-hover` | `--wpif-primary-hover` | A button on hover |
| `--wpi-account-accent-text` | white | Text on buttons |
| `--wpi-account-accent-soft` | `--wpif-primary-light` | The background of the active menu item, a field's glow |
| `--wpi-account-surface` | `--wpif-surface` | Card backgrounds |
| `--wpi-account-soft` | `--wpif-surface-2` | Secondary backgrounds (table headers, hover) |
| `--wpi-account-border` | `--wpif-border` | Borders |
| `--wpi-account-text` | `--wpif-text` | Text |
| `--wpi-account-muted` | `--wpif-text-2` | Secondary text, icons |
| `--wpi-account-radius` | `--wpif-radius-lg` | Card corner radius |
| `--wpi-account-radius-sm` | `--wpif-radius` | Button and field corner radius |
| `--wpi-account-nav-width` | `250px` | The menu's width on a computer |
| `--wpi-account-gap` | `28px` | The gap between the menu and the content |
| `--wpi-account-font` | `inherit` (the theme's font) | Typeface |

The defaults come from the InPlate tokens (`--wpif-*`), so a website that has already changed the InPlate colors has them in **My account** too. For deeper changes, turn off the InPlate look (the setting or the `wp_inplate_account_styles` filter) and style the account in the theme.

## Hooks for developers

### Filters

| Filter | Arguments | Used for |
|---|---|---|
| `wp_inplate_method_label` | `$label`, `$method` | Changing the name of the fulfillment method customers see (`pickup`, `delivery`, `dine_in`) |
| `wp_inplate_account_styles` | `$on` | `false` turns off the InPlate look in **My account** (the page is left to the theme) |
| `wp_inplate_account_downloads` | `$show` | Whether **My account** shows the **Downloads** tab. By default only when the store has a downloadable product |

### Actions

| Action | Arguments | When |
|---|---|---|
| `wp_inplate_kds_status_changed` | `$order_id`, `$from`, `$to`, `$operator_id` | A kitchen status change |
| `wp_inplate_kds_order_assigned` | `$order_id`, `$target_id`, `$kds_status` | An order is assigned |
| `wp_inplate_kds_payment_changed` | `$order_id`, `$paid`, `$operator_id` | A payment status change in the KDS |
| `wp_inplate_kds_delivery_problem` | `$order_id`, `$reason`, `$operator_id` | A courier reported a failed delivery |
| `wp_inplate_kds_delivery_problem_cleared` | `$order_id`, `$reason`, `$operator_id` | A manager cleared the return |
| `wp_inplate_kds_return_restored` | `$order_id`, `$reason`, `$operator_id` | A courier brought a return back to delivery |
| `wp_inplate_ordering_paused` | `$pause` (channels, end, reason, who, since when) | Orders are paused or the pause is changed |
| `wp_inplate_ordering_resumed` | – | Orders are resumed (manually) |
| `wp_inplate_courier_order_taken_over` | `$order_id`, `$from_id`, `$to_id` | A courier took over another courier's order with the QR code |
| `wp_inplate_consent_changed` | `$user_id`, `$consent_id`, `$given` | A customer with an account gave or withdrew a consent (at account creation, in an order, in My account) |

Example – sending an order to an external system when the kitchen marks it as ready:

```php
add_action( 'wp_inplate_kds_status_changed', function ( $order_id, $from, $to, $operator_id ) {
	if ( 'ready' !== $to ) {
		return;
	}
	// Your integration, e.g. sending a text message.
}, 10, 4 );
```

## Translations

Text domain: `wp-inplate`. Translation template: `languages/wp-inplate.pot`. Available translations: `en_US`, `uk`.

### Menu in several languages

- **Settings** in `wp_inplate_settings`: `multilingual_enabled` (the feature's switch, default `false` – then `Language::extra()` is empty, while the chosen languages and translations stay), `menu_languages` (InPlate codes from `Support\Language::CATALOG`, without the main language), `language_switch_auto` (the switch above the menu and in the order form, default `true`; the front end gets it in `wpInplateLang.auto`) and `deepl_api_key` (never returned by `GET /settings`; `deepl_api_key_saved`, deleting: `deepl_api_key_clear`).
- **Translations** in the `{prefix}inplate_translations` table (DB 1.17): `object_type`, `object_id`, `field`, `lang`, `value`, `source_hash` (the md5 of the main-language text – a different hash = “to check”). Types: `product` (`name`, `short_description`, `description`, `ingredients`), `product_cat` (`name`, `description`), `product_tag` (`name`), `addon_item` (the template item's id, or `p{product}:{id}` for a product's add-ons; `label`, `group`), `addon_option` (`{item key}:{option id}`), `variant_set` (`axis`), `variant_size` (`{set}:{size}`), `allergen` (own ones), `promotion` (a typed promotion name; `name`), `text` (`pickup_label`, `delivery_label`, `dine_in_label`, `unavailable_label`, `packaging_label`, `consent:{id}`). Products, terms and WooCommerce fields aren't changed or copied.
- **The request's language:** the InPlate REST API translates a response only with the `lang` parameter (the front end adds it itself); the team screens don't send it. Pages: `?lang=` → the `wpi_lang` cookie → `Accept-Language` → `en_US` (when enabled) → the main language; the front end gets `window.wpInplateLang`. On a cached page the front end itself switches to `?lang=` when the customer's choice differs from the page's language.
- **The order** is saved in the main language (items, add-on meta, fees); the order meta `_wp_inplate_customer_lang`. Emails to the customer (WooCommerce and InPlate) are switched to the order's language (`woocommerce_email_recipient_{id}` → `woocommerce_email_sent`), as are the thank-you page and **My account → order**; item names, add-ons, the packaging fee and allergens are translated when displayed (`woocommerce_order_item_name`, `woocommerce_order_item_get_formatted_meta_data`, `woocommerce_get_order_item_totals`).
- **A separate switch:** the `[wp-inplate-language-switch align="left|center|right"]` shortcode and the `wp-inplate/language-switch` block (the `language-switch` bundle); without a multilingual site they print nothing.
- **The theme's WooCommerce mini cart** (e.g. Kadence) is rendered in the page's language (`woocommerce_before_mini_cart` … `woocommerce_after_mini_cart`, also in the `wc-ajax=get_refreshed_fragments` fragments).
- **A language without a WordPress pack** (e.g. `uk` without the downloaded language): WordPress and WooCommerce in English, InPlate texts from `languages/wp-inplate-{locale}.mo`.
- **REST** (the `inplate_manage_menu` or `manage_woocommerce` capability; saving requires a license): `GET /wp-inplate/v1/translations/languages`, `GET /translations?language=`, `PUT /translations` (`language`, `items[{type, id, field, value}]` – the main-language text always from the server, an empty `value` deletes), `POST /translations/machine` (DeepL suggestions, without saving, up to 400 texts), `GET /translations/export?language=` (CSV), `POST /translations/import` (`language`, `csv`), `POST /translations/deepl-check`.

### Adding a language

Every language from `Support\Language::CATALOG` can be turned on straight away – the menu content is translated, and the buttons and messages are in English. A full customer interface in a new language is added by a script in the plugin's repository:

```
node tools/i18n/add-language.mjs de_DE [--deepl-key=KEY]
```

It creates the dictionary of the customer screens `assets/src/i18n/de_DE.js` (menu, widget, order form, delivery checker, cart, My account) and `languages/wp-inplate-de_DE.po` / `.mo` with the PHP texts customers see (order API, emails, thank-you page). DeepL fills in the texts or – without a key – English does; every new entry is marked `REVIEW` for checking. Running it again adds only new texts. Webpack builds every `dict-*.js`, and PHP finds the built dictionary by itself. Completeness: `node tests/i18n/check.mjs --scope=front --locale=de_DE --only=missing-po,missing-js`.
