App UI: your app inside Ewity
Apps can render their UI inside the Ewity POS console — either as native
Ewity components described in JSON, or as a fully custom UI embedded in the
app window. This page is the canonical protocol reference (v1).
Apps can render UI inside Ewity (the app-details window) without shipping any
frontend code: the app returns a JSON UI document and Ewity renders it with
native components. Apps that prefer to host their own UI return a single
IframeView component instead — both modes go through the same endpoint.
This spec is written to be consumed by developers and coding agents. The
key tables below are closed schemas: they list every accepted key —
anything not listed is rejected. Ewity validates every document server-side
and returns exhaustive, precise errors (path / message / expected /
got / hint), so iterate against the real endpoint and read the error list.
Transport
Set your app's App UI URL in the developer portal. Ewity calls it:
- Page:
GET {app_ui_url}?page=<page>&company_id=&company_name=&company_domain=&store_id=&external_account_id=
→ respond200with a UI document. - Tab content (async tabs — when your page document declares
tabs):
GET {app_ui_url}?page=<page>&tab=<key>&...→ respond200with a
tab-content document:{"version": 1, "components": [...]}. Ewity fetches
each tab lazily when the merchant first selects it. - Action (form submit / button click):
POST {app_ui_url}with body:
{
"type": "action",
"action_id": "topup_submit",
"page": "home",
"tab": "topup",
"values": {"mobile": "7771234", "amount": "100"},
"payload": {"transaction_id": "tx_1"},
"context": {"company_id": 12, "company_name": "...", "company_domain": "...",
"store_id": "pl_id_...", "external_account_id": "..."}
}tab is the currently selected tab key (null on non-tabbed pages).
→ respond 200 with an action response (below), or 422 with
{"message": "..."} to show a domain error to the merchant (e.g.
"Insufficient balance").
Requests time out after 10 seconds.
Trusting requests (verify both of these)
Every Ewity→app request (UI pages, UI actions and webhooks) is signed:
X-Ewity-Signature: t=<unix timestamp>,v1=<hex hmac_sha256(secret, "{t}.{raw body}")>
keyed with your signing secret from the developer portal (shown once your
webhook or App UI URL is saved). Verify with a constant-time compare and
reject timestamps older than 5 minutes; the raw body is the exact request
bytes (empty string for GETs).
UI requests additionally carry X-Ewity-Context: a short-lived (60s) HS256
JWT signed with the same secret. Its claims are the trusted context —
company (id/name/domain), install (store_id/external_account_id), user
(the acting POS user: id/name) plus aud (your app key), exp, jti.
Trust the token, not the query/body context fields — those are a
convenience for local debugging only. Requests also keep the legacy
Authorization: Bearer <platform API key> header for back-compat.
Iframe loads come from the merchant's browser and carry no headers — the same
JWT is appended to every IframeView url as ewity_context (5-minute
expiry). See Signed context on the iframe URL.
Shared rules
- Tones. One shared enum colors things throughout the protocol:
default | info | success | warning | danger. Optionaltonekeys
(stat chips, detail items,app_actionbuttons,tone_keycell values)
accept all five.Banner.toneandtoast.toneare required and accept
the four non-default values:info | success | warning | danger. - URLs. Every URL in the protocol (
image_url,open_url,
IframeView.url) must be https and ≤1024 chars (http allowed only for
localhostduring development). - Scalars. Wherever a "scalar" is accepted (stat/detail/row values, field
defaults, payload values): string, number, boolean or null. Strings and
numbers render verbatim — Ewity applies no number/currency/date
formatting, so pre-format display strings yourself (e.g."MVR 1,250.00").
nullrenders as—; booleans render asYes/No. No hard length cap,
but keep them short; they render in constrained layouts. - Submitted value types. In an action POST's
valuesmap:text,
mobile,textareasubmit strings;numbersubmits a JSON number;
datesubmits a"YYYY-MM-DD"string (adefaultfor a date field uses
the same format — there is no "today" token, compute it server-side);
selectalways submits the chosen optionvalueas a string, even
when the option declared a number. Parse accordingly. - Ids. Every component accepts an optional
id(string) for your own
bookkeeping. Tabs do not take anid(theirkeyserves that role). action_idis any string ≤100 chars; snake_case recommended. It is
your identifier — Ewity echoes it back verbatim on the action POST.
UI document
{
"version": 1,
"title": "Ooredoo Raastas",
"components": [ { "type": "HeaderView", ... }, ... ]
}| key | type | required | rules |
|---|---|---|---|
version | int | ✓ | must be 1 |
title | string | ≤120 — window title metadata | |
layout | string | default | fullscreen — see below | |
components | Component[] | ✓* | 1–30, rendered top to bottom |
tabs | Tab[] | ✓* | 1–8 — exactly one of components / tabs |
Layout
By default the app window renders your document with a surrounding margin,
so your UI never touches the window edges. Apps that host their entire UI
themselves can opt out:
{ "version": 1, "layout": "fullscreen",
"components": [ { "type": "IframeView", "url": "https://app.example.com/embed" } ] }"layout": "fullscreen" is only valid when components is exactly one
IframeView — the margin is removed and the iframe stretches to fill the
whole app window (its height is ignored). Any other document shape with
fullscreen is a validation error. Omit layout (or send "default")
everywhere else.
Tabs (async)
Tabs are definitions only — the page document names them, and Ewity
fetches each tab's content lazily (GET {app_ui_url}?page=<page>&tab=<key>)
when the merchant first selects it. First render stays fast and you only
compute what's actually viewed — content loads on demand, like modals.
{ "version": 1,
"tabs": [
{"key": "overview", "label": "Overview"},
{"key": "topup", "label": "Top up"}
]}| key | type | required | rules |
|---|---|---|---|
key | string | ✓ | snakecase (a-z, 0-9, ), ≤40, unique per document |
label | string | ✓ | ≤30 |
A tab-content document (your response to a ?tab= fetch) is exactly
{"version": 1, "components": [...]} — no title, no tabs (no nesting).
Inline components on a tab definition is a hard error:
{ "version": 1, "components": [ { "type": "HeaderView", "title": "..." } ] }Behaviour:
- Ewity fetches the active tab immediately (the first tab on open) and
every other tab lazily on first selection. - Tab fetches carry the same context query params and signed headers
(X-Ewity-Signature,X-Ewity-Context) as page fetches. - Content is cached per tab until a
refresh, which re-fetches the
definitions plus the active tab and invalidates the rest.refreshkeeps
the merchant on their selected tab when the new definitions still have
that key. (There is no per-tab invalidation — if an action on one tab
stales another, returnrefresh: true.) - The selected
tabkey rides on every action POST so your server knows
where the merchant was acting.
Navigation & pages
The app window opens on page=home — currently the only page Ewity
requests; the page parameter is reserved for future surfaces, and it is
echoed on action POSTs. In-app navigation is done with actions, not pages:
- Drill-down / sub-page: an
app_actionwhose response returns
{"ui": <document>}— the new document replaces the whole window content.
Auireplacement is always components-only (declaringtabsin it
is a hard error — its keys would collide with the page's own tab fetches). - Back button: there is no native back primitive. From a non-tabbed
page, answer a Backapp_actionwith the parent document as{"ui": ...}.
From a drill-down opened off a tabbed page, answer with
{"refresh": true}— it re-fetches the tabbed page and restores the
merchant's selected tab. - Tab state survives
uireplacements: the merchant's selected tab is
remembered while a drill-down is showing (actions posted from the
drill-down itself carrytab: null), andrefreshreturns them to it. refreshalways re-fetchespage=home(the page the window was
opened with), regardless of anyuireplacements since.- Sections of one screen: use
tabs, not pages.
Components
HeaderView
Page header: title, optional subtitle/image, stat chips, action buttons.
{"type": "HeaderView", "title": "Account", "subtitle": "Merchant top-up account",
"stats": [{"label": "Balance", "value": "MVR 4,520.00", "tone": "success"}],
"actions": [{"type": "app_action", "action_id": "refresh_balance", "label": "Refresh"}]}| key | type | required | rules |
|---|---|---|---|
type, id | as above | ||
title | string | ✓ | ≤120 |
subtitle | string | ≤300 | |
image_url | url | https, ≤1024 | |
stats | object[] | ≤6 of {label ✓ ≤40, value ✓ scalar, tone?} | |
actions | Action[] | ≤3 |
DetailCard
Label/value grid.
{"type": "DetailCard", "title": "Signup request", "columns": 2,
"items": [{"label": "Status", "value": "Pending review", "tone": "warning"}]}| key | type | required | rules |
|---|---|---|---|
title | string | ≤120 | |
columns | int | 1, 2 or 3 (default 1) | |
items | object[] | ✓ | 1–30 of {label ✓ ≤60, value ✓ scalar, tone?} |
Table
{"type": "Table", "title": "Transactions",
"columns": [{"key": "amount", "label": "Amount", "align": "right"},
{"key": "status", "label": "Status", "tone_key": "status_tone"}],
"rows": [{"amount": "MVR 100.00", "status": "Completed", "status_tone": "success", "tx_id": "TXN1"}],
"empty_text": "No transactions yet",
"row_action": {"type": "app_action", "action_id": "tx_details"}}| key | type | required | rules |
|---|---|---|---|
title | string | ≤120 | |
columns | object[] | ✓ | 1–8 of {key ✓ ≤60, label ✓ ≤60, align? left|center|right, tone_key? ≤60} |
rows | object[] | ✓ | 0–100; values scalar/null. Paginate in your app — one page per document |
empty_text | string | ≤200, shown when rows is empty | |
row_action | Action | app_action only; label optional here |
Rows may carry keys that are not declared as columns — they aren't
rendered, but they ride along: tone_key reads the named row key for the
cell's tone, and row_action POSTs the entire row object as the action
payload (so include your record id as an extra row key). If the
row_action also declares its own payload, the two are merged and row
keys win on conflict.
Form
{"type": "Form", "title": "Top up a customer", "action_id": "topup_submit",
"submit_label": "Top up",
"fields": [
{"key": "mobile", "label": "Customer mobile", "type": "mobile", "required": true},
{"key": "package", "label": "Package", "type": "select",
"options": [{"value": "raastas", "label": "Raastas"}]}
]}| key | type | required | rules |
|---|---|---|---|
title | string | ≤120 | |
action_id | string | ✓ | ≤100 — echoed on the submit POST |
submit_label | string | ≤40 (default "Submit") | |
fields | Field[] | ✓ | 1–20 |
Field keys (closed schema):
| key | type | required | rules |
|---|---|---|---|
key | string | ✓ | snakecase (a-z, 0-9, ), ≤60, unique per form |
label | string | ✓ | ≤80 |
type | enum | ✓ | text | number | mobile | select | textarea | date |
required | bool | client-enforced before submit | |
placeholder | string | ≤120 | |
help | string | ≤200 | |
default | scalar | pre-filled value; for select it should equal one of the option values | |
options | object[] | select only | required for select (1–50 of {value ✓ scalar, label ✓ ≤80}), rejected on other types |
Submitted values arrive in the action POST's values map keyed by field
key; date submits as YYYY-MM-DD.
Banner
{"type": "Banner", "tone": "info", "title": "Account pending",
"message": "Your signup request is being reviewed."}| key | type | required | rules |
|---|---|---|---|
tone | enum | ✓ | info | success | warning | danger |
title | string | ✓ | ≤120 |
message | string | ≤500 |
CustomHTMLView
Arbitrary HTML rendered in a sandboxed iframe (srcdoc,
allow-scripts only — no same-origin: your scripts run but cannot touch
Ewity's session, storage or DOM, and cannot navigate the window).
{"type": "CustomHTMLView", "html": "<div style=...>...</div>", "height": 400}| key | type | required | rules |
|---|---|---|---|
html | string | ✓ | ≤100 KB |
height | int | 100–4000 px (default 400) |
IframeView
Your app hosts the whole UI; Ewity embeds it (sandboxed: no top-window
navigation; referrer is not sent).
{"type": "IframeView", "url": "https://app.example.com/embed?store=...", "height": 900}| key | type | required | rules |
|---|---|---|---|
url | url | ✓ | https, ≤1024 |
height | int | 200–4000 px (default 900) |
A document whose only component is an IframeView is the "app hosts
everything" mode — pair it with root "layout": "fullscreen" to drop the
window margin and let the iframe fill the app window (see Layout;
height is ignored in fullscreen).
Signed context on the iframe URL
The iframe request is made by the merchant's browser, so it carries none
of the signed Ewity→app headers. Instead, Ewity appends the trusted context
to every IframeView url before the document reaches the browser:
https://app.example.com/embed?store=...&ewity_context=<JWT>
ewity_context is the same HS256 JWT as X-Ewity-Context (same claims,
same signing secret), minted with a 5-minute expiry so it is still valid when
the browser loads the frame. Verify it on your iframe endpoint to learn —
verifiably — which Ewity company, store and POS user the page is being shown
to:
- Split the JWT and check the HS256 signature with your signing secret
(constant-time compare). - Check
audequals your app key andexphas not passed. - Read the claims:
company(id,name,domain),install
(store_id,external_account_id) anduser(id,nameof the acting
POS user;nulloutside a POS session).
// on your iframe route
const claims = verifyContextJwt(req.query.ewity_context); // your JWT verify
if (!claims) return res.status(401).send("Reopen the app from Ewity.");
const { company, install, user } = claims; // e.g. company.domain, user.nameNever trust plain query params you put in the URL yourself for identity —
anyone can edit them. The token is the only authenticated statement of who
is on the other side. If you keep your own session, mint it at /ui fetch
time (server-to-server, fully signed) and use ewity_context to cross-check;
the token is single-shot context, not a long-lived session — issue your own
cookie/session for subsequent in-iframe requests.
Actions
Buttons (HeaderView.actions, Table.row_action) and form submits. All
four action types are valid in HeaderView.actions; Table.row_action
accepts app_action only. label is ≤40 and required on buttons (optional
on row_action). payload values must be scalars; keep payloads small —
they travel through the browser on every click.
| type | keys (closed) | behaviour |
|---|---|---|
app_action | action_id ✓ ≤100, label, payload? (flat map of scalars), confirm? ≤200, tone? | POSTs to your app; confirm shows a confirmation dialog first; tone: danger styles the button destructively |
open_url | url ✓ https ≤1024, label | opens in a new browser tab |
modal | component ✓ (Form / DetailCard / CustomHTMLView only), title? ≤120, label | opens the component in a modal (~560px wide). A modal Form submits exactly like a page Form — pair it with close_modal in your response |
refresh | label | re-fetches the current page document |
Action response
Returned from your app for a POST action. Closed key set — any
combination, at least one:
| key | type | behaviour |
|---|---|---|
ui | document | replaces the whole window content (validated like any document) |
toast | {tone ✓ info|success|warning|danger, message ✓ ≤300} | transient notification |
refresh | bool | re-fetch the page document |
close_modal | bool | close the open modal |
{"toast": {"tone": "success", "message": "Top-up completed"}, "refresh": true}Notes: ui alone does not close an open modal — when responding to a
modal Form submit, return close_modal: true alongside whatever else;
close_modal with no modal open is a no-op. Component ids are never
echoed back to you — use action payload / form values for correlation.
A 422 with {"message": "..."} shows the message as an error instead —
use it for domain rejections ("Insufficient balance"); the merchant's form
input is preserved.
Complete worked example
A minimal but full round trip. GET {app_ui_url}?page=home returns the tab
definitions:
{"version": 1, "title": "FleetFuel",
"tabs": [
{"key": "overview", "label": "Overview"},
{"key": "vehicles", "label": "Vehicles"}]}Ewity immediately fetches the first tab —
GET {app_ui_url}?page=home&tab=overview:
{"version": 1, "components": [
{"type": "HeaderView", "title": "Fleet account",
"stats": [{"label": "Balance", "value": "MVR 8,200.00", "tone": "success"}],
"actions": [
{"type": "refresh", "label": "Refresh"},
{"type": "modal", "label": "Add vehicle", "title": "Register a vehicle",
"component": {"type": "Form", "action_id": "vehicle_create",
"submit_label": "Register",
"fields": [
{"key": "plate", "label": "Plate number", "type": "text", "required": true},
{"key": "fuel", "label": "Fuel", "type": "select", "required": true,
"options": [{"value": "petrol", "label": "Petrol"},
{"value": "diesel", "label": "Diesel"}]}]}}]}]}(and ?tab=vehicles on first click):
{"version": 1, "components": [
{"type": "Table",
"columns": [{"key": "plate", "label": "Plate"},
{"key": "status", "label": "Status", "tone_key": "status_tone"}],
"rows": [{"plate": "P-2041", "status": "Active", "status_tone": "success",
"vehicle_id": 77}],
"empty_text": "No vehicles yet",
"row_action": {"type": "app_action", "action_id": "vehicle_details"}}]}Submitting the modal form POSTs to your App UI URL:
{"type": "action", "action_id": "vehicle_create", "page": "home", "tab": "overview",
"values": {"plate": "P-3155", "fuel": "diesel"},
"payload": {}, "context": {"...": "see Transport — trust X-Ewity-Context"}}and a good response is:
{"toast": {"tone": "success", "message": "P-3155 registered"},
"close_modal": true, "refresh": true}Clicking the table row POSTs action_id: "vehicle_details" with
payload: {"plate": "P-2041", "status": "Active", "status_tone": "success", "vehicle_id": 77} — answer it with {"ui": <a details document>} that
includes a Back button (app_action returning the dashboard as ui).
Renderer defaults (unhappy paths)
- A
tone_keywhose row value is missing or not a valid tone renders as
default— never an error. - A
row_actionwithout alabelmakes the whole row clickable; no button
is rendered. - Modal chrome shows the action's
title, falling back to the wrapped
component'stitle, then the actionlabel. A Form inside a modal does
not repeat its own title in the body. - Missing optional
height:CustomHTMLViewrenders at 400px,
IframeViewat 900px.
Validation errors
Invalid documents are rejected with HTTP 400, errorCode: "APP_UI_INVALID"
and data.ui_errors:
{"ui_errors": [
{"path": "$.components[1].type", "message": "Unknown component type \"Tabel\"",
"expected": "one of: HeaderView, DetailCard, Table, Form, Banner, CustomHTMLView, IframeView",
"got": "\"Tabel\" (string, 5 chars)", "hint": "Did you mean \"Table\"?"}
]}Fix every listed error — the validator reports all of them in one pass, not
just the first.
Updated 13 days ago