All recipes
πŸͺŸ

Render your app's UI inside Ewity

Serve one JSON endpoint; Ewity renders your app as native UI inside the POS.

Your app gets a window inside Ewity without shipping any frontend code: Ewity calls your App UI URL, you return a JSON UI document, and Ewity renders it with native components β€” headers, tables, forms, banners. Button clicks and form submits POST back to the same endpoint. Prefer to host your own UI? Return a single IframeView instead β€” same endpoint, same signing.

Hand it to an agent

This prompt briefs an AI coding agent to implement the entire recipe against the live docs β€” authentication, every endpoint, and webhooks.

Build it with AI

Copy this prompt into your coding agent.

Build an Ewity App UI endpoint for <your app>. Follow this spec exactly; unknown keys are hard errors.

Setup:
- Your App UI URL is configured in the Ewity developer portal (App UI tab). Saving it mints the signing secret (whsec_…) if the app doesn't already have one; copy it from the Webhooks tab and keep it server-side (e.g. EWITY_SIGNING_SECRET).
- Verify BOTH X-Ewity-Signature and X-Ewity-Context on every request, exactly as the spec's "Trusting requests" section describes, before acting on it.
- Iterate against the real endpoint: invalid documents come back as HTTP 400 APP_UI_INVALID with an exhaustive data.ui_errors list β€” fix every listed error.

The full canonical spec follows verbatim β€” it is the source of truth.

---

# Ewity App UI Protocol (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:

```json
{
  "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.

## 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

```json
{
  "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:

```json
{ "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.

```json
{ "version": 1,
  "tabs": [
    {"key": "overview", "label": "Overview"},
    {"key": "topup", "label": "Top up"}
  ]}
```

| key | type | required | rules |
|---|---|---|---|
| `key` | string | βœ“ | snake_case (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:

```json
{ "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.

```json
{"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.

```json
{"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

```json
{"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

```json
{"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 | βœ“ | snake_case (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 `value`s |
| `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

```json
{"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).

```json
{"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).

```json
{"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](#layout);
`height` is ignored in fullscreen).

## 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 |

```json
{"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 `id`s 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:

```json
{"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`:

```json
{"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):

```json
{"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:

```json
{"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:

```json
{"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`:

```json
{"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.

…or follow the build, step by step

  1. 1

    Point Ewity at your endpoint

    Set your App UI URL in the App UI tab, then grab the signing secret it mints β€” the same secret that signs your app webhooks.

    • In your app's App UI tab, set the App UI URL (https; Ewity times requests out after 10 seconds).
    • Saving it mints your signing secret (`whsec_…`) if you don't already have one β€” the secret is created when your first webhook or the App UI URL is saved, and one secret signs both.
    • Reveal and copy the secret from Webhooks β†’ Signing secret; keep it server-side (e.g. an `EWITY_SIGNING_SECRET` env var).
  2. 2

    Serve pages, tab content (GET) and actions (POST)

    One endpoint does both: GET returns the page's UI document, POST handles button clicks and form submits.

    • GET{app_ui_url}?page=home
    • GET{app_ui_url}?page=home&tab={key}
    • POST{app_ui_url}
    • GET responds 200 with a UI document β€” `{ version: 1, components: [...] }` or async `tabs` definitions. With a `tab` query param, respond with that tab's content: `{ version: 1, components: [...] }`. The window opens on `page=home`, currently the only page Ewity requests.
    • POST receives `{ type: "action", action_id, page, tab, values, payload, context }` and responds 200 with an action response β€” or 422 with `{ message }` to show a domain error to the merchant (e.g. "Insufficient balance") while preserving their form input.
  3. 3

    Verify the signature and the context JWT

    Every request is signed with your signing secret and carries a short-lived JWT with the trusted merchant context.

    • `X-Ewity-Signature: t=<unix ts>,v1=<hex hmac_sha256(secret, "{t}.{raw_body}")>` β€” recompute over the exact request bytes (empty string for GETs), compare in constant time, and reject timestamps more than 300s from now.
    • `X-Ewity-Context` is a 60-second 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 `iss` "ewity", `aud` (your app key), `exp`/`iat`/`jti`.
    • Trust the token, not the `context` query/body params β€” those are a convenience for local debugging only.
  4. 4

    Author the UI document

    Compose the page from the closed component set β€” or use tabs for sections of one screen.

    • Components (1–30, top to bottom): `HeaderView`, `DetailCard`, `Table`, `Form`, `Banner`, `CustomHTMLView`, `IframeView`. A document with a single `IframeView` is the "app hosts everything" mode.
    • Every key table in the spec is a closed schema β€” any key not listed is rejected. Values render verbatim (no number/currency/date formatting), so pre-format display strings like `"MVR 1,250.00"` yourself.
    • Use async `tabs` (1–8) for sections of one screen: the page document carries `{key, label}` definitions only, and Ewity fetches each tab's content lazily via `?tab=<key>` when the merchant selects it β€” fast first render, and you only compute what's viewed.
  5. 5

    Respond to actions

    Return any combination of toast, refresh, close_modal and ui β€” that's your whole interaction model.

    • The action response accepts `toast` (transient notification), `refresh` (re-fetch the page document), `close_modal`, and `ui` (a full document that replaces the window content) β€” any combination, at least one.
    • Drill-downs and back buttons are both `app_action`s whose response returns `{ ui: <document> }`; there is no native back primitive. `refresh` always re-fetches `page=home`.
    • A modal `Form` submits exactly like a page form β€” pair it with `close_modal: true` in your response.
  6. 6

    Test in Ewity & iterate on validation errors

    Open your app inside Ewity POS and let the validator's exhaustive error lists drive you to a clean document.

    • Whitelist a test business on the Businesses tab (or use your seeded sandbox company), then open your app inside Ewity POS to see the rendered document.
    • Invalid documents are rejected with HTTP 400, `errorCode: "APP_UI_INVALID"` and `data.ui_errors[]` β€” each error carries `path` / `message` / `expected` / `got` / `hint`, and the validator reports all of them in one pass. Fix every listed error, not just the first.

Ready to build render your app's ui inside ewity?

Create an app to get your API key, then follow the steps above.