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=
    → respond 200 with a UI document.
  • Tab content (async tabs — when your page document declares tabs):
    GET {app_ui_url}?page=<page>&tab=<key>&... → respond 200 with 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. Optional tone keys
    (stat chips, detail items, app_action buttons, tone_key cell values)
    accept all five. Banner.tone and toast.tone are 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
    localhost during 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").
    null renders as ; booleans render as Yes / No. No hard length cap,
    but keep them short; they render in constrained layouts.
  • Submitted value types. In an action POST's values map: text,
    mobile, textarea submit strings; number submits a JSON number;
    date submits a "YYYY-MM-DD" string (a default for a date field uses
    the same format — there is no "today" token, compute it server-side);
    select always submits the chosen option value as 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 an id (their key serves that role).
  • action_id is 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", ... }, ... ]
}
keytyperequiredrules
versionintmust be 1
titlestring≤120 — window title metadata
layoutstringdefault | fullscreen — see below
componentsComponent[]✓*1–30, rendered top to bottom
tabsTab[]✓*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"}
  ]}
keytyperequiredrules
keystringsnakecase (a-z, 0-9, ), ≤40, unique per document
labelstring≤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. refresh keeps
    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, return refresh: true.)
  • The selected tab key 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_action whose response returns
    {"ui": <document>} — the new document replaces the whole window content.
    A ui replacement is always components-only (declaring tabs in 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 Back app_action with 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 ui replacements: the merchant's selected tab is
    remembered while a drill-down is showing (actions posted from the
    drill-down itself carry tab: null), and refresh returns them to it.
  • refresh always re-fetches page=home (the page the window was
    opened with), regardless of any ui replacements 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"}]}
keytyperequiredrules
type, idas above
titlestring≤120
subtitlestring≤300
image_urlurlhttps, ≤1024
statsobject[]≤6 of {label ✓ ≤40, value ✓ scalar, tone?}
actionsAction[]≤3

DetailCard

Label/value grid.

{"type": "DetailCard", "title": "Signup request", "columns": 2,
 "items": [{"label": "Status", "value": "Pending review", "tone": "warning"}]}
keytyperequiredrules
titlestring≤120
columnsint1, 2 or 3 (default 1)
itemsobject[]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"}}
keytyperequiredrules
titlestring≤120
columnsobject[]1–8 of {key ✓ ≤60, label ✓ ≤60, align? left|center|right, tone_key? ≤60}
rowsobject[]0–100; values scalar/null. Paginate in your app — one page per document
empty_textstring≤200, shown when rows is empty
row_actionActionapp_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"}]}
 ]}
keytyperequiredrules
titlestring≤120
action_idstring≤100 — echoed on the submit POST
submit_labelstring≤40 (default "Submit")
fieldsField[]1–20

Field keys (closed schema):

keytyperequiredrules
keystringsnakecase (a-z, 0-9, ), ≤60, unique per form
labelstring≤80
typeenumtext | number | mobile | select | textarea | date
requiredboolclient-enforced before submit
placeholderstring≤120
helpstring≤200
defaultscalarpre-filled value; for select it should equal one of the option values
optionsobject[]select onlyrequired 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."}
keytyperequiredrules
toneenuminfo | success | warning | danger
titlestring≤120
messagestring≤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}
keytyperequiredrules
htmlstring≤100 KB
heightint100–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}
keytyperequiredrules
urlurlhttps, ≤1024
heightint200–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:

  1. Split the JWT and check the HS256 signature with your signing secret
    (constant-time compare).
  2. Check aud equals your app key and exp has not passed.
  3. Read the claims: company (id, name, domain), install
    (store_id, external_account_id) and user (id, name of the acting
    POS user; null outside 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.name

Never 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.

typekeys (closed)behaviour
app_actionaction_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_urlurl ✓ https ≤1024, labelopens in a new browser tab
modalcomponent ✓ (Form / DetailCard / CustomHTMLView only), title? ≤120, labelopens the component in a modal (~560px wide). A modal Form submits exactly like a page Form — pair it with close_modal in your response
refreshlabelre-fetches the current page document

Action response

Returned from your app for a POST action. Closed key set — any
combination, at least one:

keytypebehaviour
uidocumentreplaces the whole window content (validated like any document)
toast{tone ✓ info|success|warning|danger, message ✓ ≤300}transient notification
refreshboolre-fetch the page document
close_modalboolclose 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_key whose row value is missing or not a valid tone renders as
    default — never an error.
  • A row_action without a label makes the whole row clickable; no button
    is rendered.
  • Modal chrome shows the action's title, falling back to the wrapped
    component's title, then the action label. A Form inside a modal does
    not repeat its own title in the body.
  • Missing optional height: CustomHTMLView renders at 400px,
    IframeView at 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.


Did this page help you?