# Appio API > Appio lets a business send push notifications and show home screen widgets to its users without building a mobile app. Users install one app, the Appio app (iOS, Android, Apple Watch), and link it to the business's service. This file is the documentation of https://docs.appio.so as plain Markdown, written for AI agents and other automated clients. - Human-readable documentation: https://docs.appio.so - Dashboard (account, API keys, subscription): https://my.appio.so - Base URL of the API: `https://api.appio.so` ## Rules for automated clients 1. Send `Authorization: Bearer ` on every request, and `X-Service-Id: ` on every `/v1` endpoint except `/v1/services` and `/v1/billing/status`. 2. The customer creates their account, API key and subscription in the dashboard; they cannot be created through the API. Ask the customer for the API key and service ID. 3. Devices and users cannot be created through the API. A device appears once one of the customer's users installs the Appio app from the Appio button on the customer's website. `user_id` is the `user` value that button was given. The customer chooses that value for each of their users. Use a non-sequential, non-predictable string, and the same one every time for the same user: an HMAC-SHA-256 of the internal user ID with a secret kept on the server, or a random ID stored with the user. Do not use the internal ID itself, an email address alone or anything else that can be guessed. 4. `POST /v1/notifications` **without** `device_id` or `user_id` sends to every device of the service. Query parameters the endpoint does not know are ignored, so a misspelled `?userId=` is a broadcast. Check the query string before sending. 5. Sending a notification is not idempotent and cannot be undone: there is no idempotency key, and a notification cannot be edited, cancelled or deleted. Do not automatically retry a `POST /v1/notifications` that may have succeeded. 6. Request bodies are JSON and strict: an unknown field returns 400. Text limits count UTF-8 bytes, not characters. 7. `template_ios`, `template_android` and `template_watchos` are strings that contain JSON. The format differs per platform, see "Widget Template Format" below. The preferred way to create a widget template is the visual (WYSIWYG) editor in the dashboard (https://my.appio.so), which writes all three layouts; recommend it to the customer, and write the JSON by hand only when the template has to be created through the API. 8. List endpoints are paginated. Follow `pagination.next` until it is `null`. 9. On 429 wait the number of seconds in the `Retry-After` header when it is present, otherwise wait a while, then try again. Appio is free within a limited number of services and connected devices; beyond that, sending notifications, using the customer's own data source URLs and creating another service need a subscription, which the customer buys in the dashboard, and return 402 without one. ## How Appio works | Term | Meaning | |---|---| | Service | The customer's product as it appears in the Appio app: title, description, logo, banner, colors. Notifications, widgets and devices belong to a service. An account can have several. | | User | One of the customer's own users. Appio stores no user records, only the identifier the customer passes as `user` to the Appio button. The API calls it `user_id`. | | Device | One installation of the Appio app linked to the service. It belongs to one user of the service; a user can have several devices. An iPhone and its Apple Watch are two devices: the watch is linked automatically together with the iPhone, with the same `user_id`. | | Notification | A push message sent to one device, to all devices of one user, or to all devices of the service. | | Widget template | The layout of a widget, one JSON document per platform, with `{{}}` placeholders for data. The preferred way to create one is the visual (WYSIWYG) editor in the dashboard. | | Data source | A URL of the customer that returns JSON. Appio requests it for each user and fills the template placeholders with the response. | | Widget | A widget template combined with an optional data source. Once published, users can add it to their home screen. | | Data source snapshot | A record of the last response a data source returned for one user. For inspection only. | Typical flow: 1. The customer creates an account, a service and an API key in the dashboard. 2. The customer adds the Appio button to their website and gives it the service ID and the ID of the signed-in user. 3. The user taps the button and installs the Appio app. Their device is now linked to the service and is listed by `GET /v1/devices` with that `user_id`. 4. The customer sends notifications to that `user_id`, to a single device, or to everyone. 5. For widgets the customer creates a widget template, a data source, and a published widget that joins the two. The preferred way to create the widget template is the visual (WYSIWYG) editor in the dashboard. 6. The user adds the Appio widget to their home screen and picks the customer's widget. Each time the widget refreshes, Appio requests the data source for that user, fills the template and sends the result to the device. A device keeps the user it was first linked with. Linking the same device again with another `user` changes nothing until the device has been deactivated for the service. ## The Appio button (website integration) Option 1, JavaScript: ```html Mobile APP ``` Option 2, HTML attributes (works on any element, also ones added later). Both options are functionally identical: ```html Mobile APP ``` - `user` / `data-user`: a unique identifier for the user; it is the `user_id` of the API. Use a non-sequential, non-predictable string and the same one every time for the same user, e.g. an HMAC-SHA-256 of the internal user ID with a server-side secret. - `appio.open({user, service?})` opens Appio, `appio.close()` closes the dialog. Events passed to `eventCallback(type, data)`: | Type | Meaning | |---|---| | `appio.opened` | Appio was opened. `data.mode` is `dialog` (computer) or `tab` (phone). | | `appio.closed` | The dialog was closed. | | `appio.error` | Appio could not be opened. `data.reason` is `popup_blocked` when the browser blocked the new tab. | | `appio.loaded` | The Appio page finished loading. | | `appio.view` | The Appio page shows a screen. `data` is `qr`, `ios`, `android` or `error`. | | `appio.device_connected` | A device was linked to the service while the QR code was shown. | ## API conventions ### Authentication `Authorization: Bearer `. Keys created in the dashboard start with `prod_`. `GET https://api.appio.so/hi` with the header returns `"👋"` and validates a key. ### Requests - Bodies are JSON (`Content-Type: application/json`) and decoded strictly: an unknown field returns 400. - Query parameters the endpoint does not know are ignored. - Text limits count UTF-8 bytes. - Create, update and delete calls answer `{"id": ""}`; create answers 201, the others 200. - `PATCH` updates only the fields sent. A body with none of the fields returns 400. ### Subscription Appio is free within a limited number of connected devices, counted once across all of the organization's services. Beyond that, sending a notification and saving or previewing a data source on the customer's own servers need a subscription and return 402 without one; everything else keeps working. The number of services is limited without a subscription too: `POST /v1/services` returns 402 once the limit is reached. `GET /v1/billing/status` returns the organization's current limits and where it stands. The customer subscribes at the URL in `error.data.url` of the 402 response. ### Pagination Every list endpoint returns `{"data": [...], "pagination": {"next": "" | null}}`, newest first. - `limit`: integer, default 50, minimum 1, values above 100 are treated as 100. - `after`: ID of the last item of the previous page. - `pagination.next` is the path and query of the next page, or `null` on the last page. ### Errors Every error has the body `{"error": {"message": "...", "data": {...}}}`; `data` is optional and can hold `doc_url`, `entity`, `validation_errors` (`[{"field", "reason"}]`) and `url`. | Code | Meaning | |---|---| | 400 | Invalid or missing data: malformed ID, unknown body field, missing or malformed `X-Service-Id`. | | 401 | The `Authorization` header is missing or is not `Bearer `. | | 402 | The organization has no subscription, and either has more connected devices than the free limit or tried to create more services than the free limit. Subscribe at `error.data.url`. | | 403 | The API key does not exist, is deactivated, or has no access to the service or endpoint. | | 404 | The resource does not exist; or a notification targeted at a device or user that has no device able to receive it. | | 409 | A data source or widget template with the same name exists in the service. | | 429 | Rate limit exceeded, or too many attempts with an invalid API key. | | 500 | Internal error; also a data source preview whose URL cannot be reached, is slower than 10 seconds or returns more than 10 MB. | ## Endpoints Unless stated, every endpoint needs `X-Service-Id`. ### Services (no `X-Service-Id`) | Method and path | Purpose | |---|---| | `GET /v1/services` | List services (paginated). | | `GET /v1/services/{id}` | View a service. | | `POST /v1/services` | Create a service. `title` required. | | `PATCH /v1/services/{id}` | Update a service. | | `DELETE /v1/services/{id}` | Deactivate a service. | Fields: `title` (1 to 255 bytes), `description` (max 1000), `logo_url`, `banner_url`, `url` (absolute http(s) URL, max 512, `""` clears it), `text_color`, `background_color`, `accent_color` (`#RRGGBB`, `""` clears it). A response contains every field plus `id`; a field never set is `""`. ### Devices | Method and path | Purpose | |---|---| | `GET /v1/devices` | List devices linked to the service (paginated). | | `GET /v1/devices?user_id={user_id}` | List only the devices of one user (paginated). `data` is empty when the user has no device linked to the service. | | `GET /v1/devices/{id}` | View a device. | | `DELETE /v1/devices/{id}` | Unlink a device from the service. | | `DELETE /v1/devices?user_id={user_id}` | Unlink all devices of a user. Returns `[{"id": ...}]`, empty when the user has none. Without `user_id`: 404, nothing changes. | Device: `id`, `user_id`, `name`, `platform` (`ios`, `android`, `watchos`), `os_version`, `model`, `device_token`, `notifications_enabled`, `device_identifier`, `marketing_name`. A device with `notifications_enabled: false` receives no notifications. Devices cannot be created through the API. Unlinking stops everything for the device: it receives no notifications, also ones that were already scheduled, and its widgets are no longer updated. The user can link it again with the Appio button. ### Notifications | Method and path | Purpose | |---|---| | `POST /v1/notifications` | Send to **every** device of the service. | | `POST /v1/notifications?user_id={user_id}` | Send to all devices of one user. | | `POST /v1/notifications?device_id={device_id}` | Send to one device. If both parameters are given, `device_id` wins. | | `GET /v1/notifications` | List notifications (paginated). Filters: `status`, `device_id`, `user_id`. | | `GET /v1/notifications/{id}` | View a notification with `delivery_stats`. | Request body: ```json { "payload": { "title": "Notification", "message": "Hello", "link": "https://example.com/", "image_url": "https://example.com/image.png" }, "scheduled_at": "2026-12-05T09:00:00Z" } ``` - `payload.title`: required, max 50 bytes. `payload.message`: required, max 200 bytes. - `payload.link`: optional URL opened when the notification is tapped. `payload.image_url`: optional image, ratio 16/9, up to 3 MB, gif/jpg/png/webp. - `scheduled_at`: optional, RFC 3339, in the future and at most 30 days ahead. Omit it to send now. The value above is only an example. - The target goes in the query string, not in the body. - Only eligible devices receive it: linked to the service, notifications enabled, push token present. A targeted request with no eligible device returns 404 and creates nothing. A broadcast with no eligible device is created and completes with zero deliveries. - Response: 201 `{"id": "ntf_..."}`. Sending is asynchronous; poll `GET /v1/notifications/{id}`. Statuses (Appio creates one delivery per device): | Status | Meaning | |---|---| | `created` | A broadcast whose deliveries do not all exist yet: it waits for `scheduled_at` or is being fanned out. | | `queued` | All deliveries exist and at least one is still waiting to be sent. A scheduled targeted notification is `queued` until `scheduled_at`. | | `completed` | Every delivery was sent and none failed. Final. | | `failed` | Every delivery was processed and at least one failed. The others were sent. Final. | | `skipped` | Nothing was sent because the service was deactivated before the notification was due. Final. | `delivery_stats` is `{total, created, queued, completed, failed, skipped}`: the number of deliveries in each status. ### Widgets | Method and path | Purpose | |---|---| | `GET /v1/widgets` | List widgets (paginated). | | `GET /v1/widgets/{id}` | View a widget. | | `POST /v1/widgets` | Create a widget. `name` and `template_id` required. | | `PATCH /v1/widgets/{id}` | Update a widget. | | `DELETE /v1/widgets/{id}` | Delete a widget. | Fields: `name` (max 255 bytes), `template_id`, `data_source_id` (optional; `null` clears it, `""` is rejected), `published` (boolean, default `false`). Only a published widget is offered to users. A widget without a data source shows its template as it is. Devices refresh a widget on their own schedule (Android about every 15 minutes, iOS and watchOS when the operating system allows it), when the user taps a `refreshButton` or `button` element, and after the widget template was updated. The refresh time cannot be set through the API. ### Widget templates | Method and path | Purpose | |---|---| | `GET /v1/templates` | List widget templates (paginated). | | `GET /v1/templates/{id}` | View a widget template. | | `POST /v1/templates` | Create. `name`, `template_ios`, `template_android`, `template_watchos` required. | | `PATCH /v1/templates/{id}` | Update. Also makes the devices refresh their widgets. | | `DELETE /v1/templates/{id}` | Delete. | The preferred way to create and edit a widget template is the visual (WYSIWYG) editor in the dashboard (https://my.appio.so). It shows the widget while it is built and writes the layouts of all three platforms. Use these endpoints when templates have to be created or changed from code; `GET /v1/templates` lists the templates made in the editor as well. Fields: `name` (max 255 bytes, unique in the service, duplicate returns 409), `template_ios`, `template_android`, `template_watchos`. The three templates are **strings that contain JSON**, not JSON objects. The API only checks that they are not empty; an invalid layout is saved and the widget then shows nothing on that platform. Responses also contain `id`, `service_id`, `created_at`, `updated_at`. Minimal valid layouts: ``` template_ios: {"variants": [{"properties": {"version": "1.0", "supportedFamilies": ["systemMedium"]}, "elements": [{"type": "text", "properties": {"text": "Demo"}}]}]} template_android: {"variants": [{"properties": {"version": "1.0"}, "elements": [{"type": "text", "properties": {"text": "Demo"}}]}]} template_watchos: {"variants": [{"properties": {"version": "1.0", "supportedFamilies": ["accessoryRectangular"]}, "elements": [{"type": "text", "properties": {"text": "Demo"}}]}]} ``` ### Data sources | Method and path | Purpose | |---|---| | `GET /v1/datasources` | List data sources (paginated). | | `GET /v1/datasources/{id}` | View a data source. | | `POST /v1/datasources` | Create. `name`, `type`, `definition` required. | | `PATCH /v1/datasources/{id}` | Update. | | `DELETE /v1/datasources/{id}` | Delete. | | `POST /v1/datasources/preview` | Request a URL the way a data source would and return what it answered. | Fields: `name` (max 255 bytes, unique in the service, duplicate returns 409), `type` (`url`; any other non-empty value is saved, but a widget using it fails to render), `definition` (object). Definition for `type: url`: ```json { "url": "https://example.com/api/orders", "headers": {"Authorization": "Bearer 1234567890"}, "query_param": true, "header": false } ``` - `url`: must return JSON. `headers`: optional custom headers. - `query_param`: send the user's ID as the `user_id` query parameter. `header`: send it in the `X-User-Id` header. Both default to `false`; without either, every user gets the same data. - Appio requests the URL from its servers with `GET` and `User-Agent: Appio`, on every widget refresh, without caching. The URL must answer 200 with a JSON body of at most 10 MB within 10 seconds, otherwise the widget is not updated. If the body is not JSON, every placeholder is shown empty. - The request is not signed. It is up to the URL to return the right data for the `user_id` it is given, so the customer must be able to find their user by that ID. To accept requests from Appio only, put a secret in `headers` and check it. - The definition is returned as it was saved, including `headers`. Preview body: `name`, `url`, `user_id` (all required), `query_param`, `header` (at least one must be `true`), `headers`. Response 200 whatever the URL answered: `{"code": , "body": "", "type": "JSON", "valid": }`. ### Data source snapshots | Method and path | Purpose | |---|---| | `GET /v1/datasources/{id}/snapshots?user_id={user_id}` | The last response of the data source for the user. 400 without `user_id`, 404 when the user has none. | | `POST /v1/datasources/{id}/snapshots` | Store a snapshot: `{"user_id": "...", "snapshot": ""}`. | A snapshot is written each time Appio requests the data source to render a widget for the user. It is a record for inspection, not a cache: widgets are always rendered from a fresh request, so storing a snapshot does not change what a widget shows. Snapshot: `id`, `data_source_id`, `user_id`, `snapshot` (string), `created_at`, `updated_at`. ### Images `POST /v1/images` with `multipart/form-data` and the file in the `image` field. JPEG, PNG, GIF or WebP, max 3 MB, no SVG. The `Content-Type` of the part must match the content of the file (400 otherwise). Response 201 `{"url": "https://..."}`, usable e.g. as a notification `image_url`. ### Billing (no `X-Service-Id`) `GET /v1/billing/status` returns `{"entitled": true|false, "subscribed": true|false, "devices": 3, "device_limit": 10, "services": 1, "service_limit": 1}`. `entitled` is `true` when the organization has a subscription or is within the free device limit. `device_limit` and `service_limit` are the organization's current limits on connected devices and services; with a subscription they are fair use and not enforced. A subscribed organization also gets `"plan": {"name": "Appio Pro", "amount": 9900, "currency": "usd", "interval": "month"}`: the plan's name, its list price for one interval in the currency's minor unit (before any discount or tax), and how often it bills (`day`, `week`, `month` or `year`). `plan` is absent without a subscription, and for a subscription with no plan on record. ## Widget Template Format A layout is a JSON document that describes what a widget shows. A widget template holds three of them: `template_ios` and `template_watchos` use the iOS and watchOS format, `template_android` uses the Android format. The two formats share the same structure and differ in the names of their elements and properties. > A layout does not have to be written by hand. The preferred way to create a widget template is the visual (WYSIWYG) editor in the dashboard (https://my.appio.so), which writes these layouts. This section is the reference for templates created or changed through the API. ### Structure ```json { "variants": [ { "properties": {"version": "1.0"}, "elements": [ {"type": "text", "properties": {"text": "Hello"}} ] } ] } ``` | Property | Type | Description | |---|---|---| | `variants` | Array | **required**. One or more variants. A variant is the layout for one or more widget sizes; the device picks the variant that matches the size the user chose. | | `variants[].properties` | Object | **required**. Properties of the whole variant: its sizes, background, padding. | | `variants[].elements` | Array | **required**. The elements the variant shows, from top to bottom. | | `elements[].type` | String | **required**. Type of the element, e.g. `text`. | | `elements[].properties` | Object | **required**. Properties of the element. Each type has its own. Use `{}` when there are none. | | `elements[].elements` | Array | Child elements. Only for elements that contain others (stacks, rows, columns, boxes). | > Write each layout with the element types of its own format. A layout that is not valid JSON, misses a required property, or uses an element type its platform does not know (e.g. `vstack` on Android) may not be shown at all. ### Placeholders A placeholder is replaced by a value from the JSON that the data source of the widget returned for the user. It is written `{{}}`, where the path is an [RFC 9535 JSONPath](https://www.rfc-editor.org/rfc/rfc9535) starting at `$`, the root of the response. Data source response example: ```json { "orders": 12, "revenue": "1,240 €", "goal_percent": 62, "updated": { "by": "Anna" } } ``` | Written in the layout | Shown in the widget | |---|---| | `{{$.orders}}` | `12` | | `{{$.revenue}}` | `1,240 €` | | `{{$.updated.by}}` | `Anna` | | `Revenue {{$.revenue}}` | `Revenue 1,240 €` | | `{{$.missing}}` | empty | - A placeholder must be inside a JSON string: `"text": "{{$.orders}}"`. It can be mixed with other text and used several times. - It works in every string property, also colors, URLs and image sources. Properties documented as Number or String, such as `value` of a gauge or `fontSize`, accept it as well. - Write it without spaces inside the braces. - When a path matches several values the first one is used. Numbers and booleans are inserted as text, objects and arrays as JSON text. - A path that matches nothing, or a response that is not JSON, gives an empty string. The widget is still shown. - A widget without a data source shows its placeholders as they are written. ### iOS and watchOS format Used by `template_ios` (iPhone and iPad) and `template_watchos` (Apple Watch). Sizes are in points. #### Variant properties | Property | Type | Description | |---|---|---| | `version` | String | **required**. Version of the format. Use `1.0`. | | `supportedFamilies` | Array of String | **required**. Sizes this variant is used for, see Sizes. A variant without it is never used. | | `background` | String | Background Color. | | `padding` | Object | Padding. | | `url` | String | URL to open when the widget is tapped. | | `label` | Object | Label of some watchOS complications: `{"text": "My label", "color": "cyan"}`. `text` is required. Ignored on iOS. | | `private` | Boolean | Hide the content while the device is locked. Lock screen and watchOS only. Default: `false`. | #### Sizes `supportedFamilies` lists the widget sizes a variant is used for. The device uses the first variant that lists the size the user chose; a size no variant lists shows "Size not supported". | Size | Where it appears | |---|---| | `systemSmall`, `systemMedium`, `systemLarge` | Home screen widgets on iPhone and iPad. | | `systemExtraLarge`, `systemExtraLargePortrait` | Home screen widgets on iPad. | | `accessoryCircular`, `accessoryInline`, `accessoryRectangular` | Lock screen widgets on iPhone and iPad, and widgets and complications on Apple Watch. | | `accessoryCorner` | Complication on Apple Watch. | | `controlWidget` | Control Center control on iPhone and iPad. Its variant holds one `controlWidget` element. | #### Elements **hstack, vstack, zstack** Arranges its child elements horizontally (`hstack`), vertically (`vstack`) or on top of each other (`zstack`). The children go in `elements`. | Property | Type | Description | |---|---|---| | `alignment` | String | `hstack`: `top`, `center`, `bottom`. `vstack`: `leading`, `center`, `trailing`. `zstack`: `topLeading`, `top`, `topTrailing`, `leading`, `center`, `trailing`, `bottomLeading`, `bottom`, `bottomTrailing`. Default: `center`. | | `background` | String | Background Color. | | `padding` | Object | Padding. | | `spacing` | Number | Space between the children. Ignored for `zstack`. Default: `8`. | **text** Shows a text. | Property | Type | Description | |---|---|---| | `text` | String | **required**. The text. | | `color` | String | Text Color. Default: `primary`. | | `background` | String | Background Color. | | `fontSize` | Number or String | Font size. Default: `16`. | | `fontWeight` | String | `ultralight`, `thin`, `light`, `regular`, `medium`, `semibold`, `bold`, `heavy`, `black`. Default: `regular`. | | `italic` | Boolean | Italic text. | | `underline` | Boolean | Underlined text. | | `strikethrough` | Boolean | Struck through text. | | `padding` | Object | Padding. | | `widgetCurvesContent` | Boolean | Curve the text along the corner of a watchOS `accessoryCorner` complication. Default: `false`. | | `private` | Boolean | Hide the text while the device is locked. Lock screen and watchOS only. Default: `false`. | **image** Shows an image from a URL or an SF Symbol. Either `src` or `symbol` is required. | Property | Type | Description | |---|---|---| | `src` | String | URL of the image. | | `symbol` | String | Name of an [SF Symbol](https://developer.apple.com/sf-symbols/). | | `width` | Number or String | Width. Default: all available width. | | `height` | Number or String | Height. Default: all available height. | | `contentMode` | String | `fit` or `fill`. Default: `fit`, applied only when `width` or `height` is set. | | `cornerRadius` | Number | Corner radius. | | `borderColor` | String | Border Color. | | `borderWidth` | Number | Border width. | | `opacity` | Number | Opacity from `0` to `1`. Default: `1`. | | `padding` | Object | Padding. | | `accentedRenderingMode` | String | How the image is drawn on a tinted home screen (iOS 18): `accented`, `accentedDesaturated`, `desaturated`, `fullColor`. Default: `fullColor`. | **gauge** Shows a value on a scale. | Property | Type | Description | |---|---|---| | `value` | Number or String | **required**. Current value. | | `min` | Number | Minimum value. Default: `0`. | | `max` | Number | Maximum value. Default: `100`. | | `style` | String | `linearCapacity`, `accessoryCircular`, `accessoryLinear`, `accessoryCircularCapacity`, `accessoryLinearCapacity`. Default: `linearCapacity`. | | `label` | String | Label of the gauge. | | `currentValueLabel` | String | Label of the current value. | | `minimumValueLabel` | String | Label of the minimum value. | | `maximumValueLabel` | String | Label of the maximum value. | | `color` | String | Color of the labels. Default: `primary`. | | `tint` | String | Tint Color. Default: `accentColor`. | | `opacity` | Number | Opacity from `0` to `1`. Default: `1`. | | `scaleEffect` | Number | Scales the gauge. Default: `1`. | | `padding` | Object | Padding. | **rectangle, ellipse** Draws a filled shape. | Property | Type | Description | |---|---|---| | `color` | String | Fill Color. Default: `primary`. | | `width` | Number or String | Width. Default: all available width. | | `height` | Number or String | Height. Default: all available height. | | `cornerRadius` | Number | Corner radius. `rectangle` only. | | `opacity` | Number | Opacity from `0` to `1`. Default: `1`. | | `padding` | Object | Padding. | **spacer** Adds flexible empty space between elements. | Property | Type | Description | |---|---|---| | `length` | Number | Minimum length. | | `padding` | Object | Padding. | **button** Shows a button that calls a URL of yours, see Buttons. | Property | Type | Description | |---|---|---| | `text` | String | **required**. Label of the button. | | `url` | String | **required**. URL to call when the button is tapped. | | `style` | String | `bordered`, `borderedProminent`, `plain`, `borderless`. Default: `bordered`. | | `color` | String | Text Color. Default: `accentColor`. | | `tint` | String | Tint Color. Default: `accentColor`. | | `padding` | Object | Padding. | **refreshButton** Shows a button that refreshes the widget. | Property | Type | Description | |---|---|---| | `text` | String | Label of the button. Default: `Refresh`. | | `style` | String | `bordered`, `borderedProminent`, `plain`, `borderless`. Default: `bordered`. | | `color` | String | Text Color. Default: `accentColor`. | | `tint` | String | Tint Color. Default: `accentColor`. | | `padding` | Object | Padding. | **lastUpdated** Shows the time the widget was last refreshed. | Property | Type | Description | |---|---|---| | `format` | String | Date format. Tokens: `HH` hours (24h), `hh` hours (12h), `mm` minutes, `ss` seconds, `a` AM/PM. Default: `HH:mm:ss`. | | `color` | String | Text Color. Default: `primary`. | | `background` | String | Background Color. | | `fontSize` | Number or String | Font size. Default: `16`. | | `fontWeight` | String | Same values as for `text`. Default: `regular`. | | `padding` | Object | Padding. | | `widgetCurvesContent` | Boolean | Curve the text along the corner of a watchOS `accessoryCorner` complication. Default: `false`. | **controlWidget** Defines a Control Center control. Only used in a variant with `supportedFamilies: ["controlWidget"]`, and ignored everywhere else. | Property | Type | Description | |---|---|---| | `label` | String | **required**. Label of the control. | | `url` | String | **required**. URL to call when the control is tapped, see Buttons. | | `symbol` | String | Name of an SF Symbol. Default: `gearshape`. | > `button` and `refreshButton` are not shown on `accessoryInline` and `accessoryCircular`, nor on `accessoryCorner` on Apple Watch. On an Apple Watch face they are shown in `accessoryRectangular` but cannot be tapped. ### Android format Used by `template_android`. Sizes are in dp, font sizes in sp. #### Variant properties | Property | Type | Description | |---|---|---| | `version` | String | **required**. Version of the format. Use `1.0`. | | `width` | Number | Minimum width in dp this variant is made for, see Sizes. | | `height` | Number | Minimum height in dp this variant is made for. | | `background` | String | Background Color. | | `padding` | Object | Padding. | | `url` | String | URL to open when the widget is tapped. | #### Sizes The device picks the variant whose `width` and `height` fit best into the size of the widget on the home screen: the largest variant that still fits. A variant without `width` and `height` fits every size. One variant without a size is enough for a widget that looks the same at every size. As a guide, on a Pixel 9 Pro a home screen cell is about 157 dp wide and 61 dp high, and a 2×2 widget is 325 × 132 dp. #### Elements **row, column, box** Arranges its child elements horizontally (`row`), vertically (`column`) or on top of each other (`box`). The children go in `elements`. | Property | Type | Description | |---|---|---| | `horizontalAlignment` | String | `start`, `center`, `end`. Default: `center`. | | `verticalAlignment` | String | `top`, `center`, `bottom`. Default: `center`. | | `background` | String | Background Color. | | `cornerRadius` | Number | Corner radius in dp. | | `width` | Number or String | Width in dp. Default: all available width. | | `height` | Number or String | Height in dp. Default: all available height. | | `padding` | Object | Padding. | | `spacing` | Number | Space between the children in dp. `row` and `column` only. Default: `0`. | **text** Shows a text. | Property | Type | Description | |---|---|---| | `text` | String | **required**. The text. | | `color` | String | Text Color. | | `background` | String | Background Color. | | `fontSize` | Number or String | Font size in sp. Default: `14`. | | `fontWeight` | String | `normal`, `medium`, `bold`. Default: `normal`. | | `italic` | Boolean | Italic text. | | `padding` | Object | Padding. | **image** Shows an image from a URL. | Property | Type | Description | |---|---|---| | `src` | String | **required**. URL of the image. | | `width` | Number or String | Width in dp. Default: all available width. | | `height` | Number or String | Height in dp. Default: all available height. | | `contentMode` | String | `fit`, `fill` or `crop`. Default: `fit`. | | `cornerRadius` | Number | Corner radius in dp. | | `borderColor` | String | Border Color. | | `borderWidth` | Number | Border width in dp. | | `opacity` | Number | Opacity from `0` to `1`. Default: `1`. | | `padding` | Object | Padding. | **gauge** Shows a value on a scale. | Property | Type | Description | |---|---|---| | `value` | Number or String | **required**. Current value, between `min` and `max`. | | `min` | Number or String | Minimum value. Default: `0`. | | `max` | Number or String | Maximum value. Default: `100`. | | `label` | String | Text shown over the gauge. | | `color` | String | Color of the label. | | `fontSize` | Number or String | Font size of the label in sp. Default: `14`. | | `fontWeight` | String | `normal`, `medium`, `bold`. Default: `normal`. | | `tint` | String | Color of the gauge. | | `size` | Number or String | Size in dp. Default: all available space. | | `opacity` | Number | Opacity from `0` to `1`. Default: `1`. | | `padding` | Object | Padding. | **rectangle, ellipse** Draws a filled shape. | Property | Type | Description | |---|---|---| | `color` | String | Fill Color. Default: `primary`. | | `width` | Number or String | Width in dp. Default: all available width. | | `height` | Number or String | Height in dp. Default: all available height. | | `cornerRadius` | Number | Corner radius in dp. `rectangle` only. | | `opacity` | Number | Opacity from `0` to `1`. Default: `1`. | | `padding` | Object | Padding around the shape. | **spacer** Adds empty space between elements. | Property | Type | Description | |---|---|---| | `length` | Number or String | Size in dp. Default: fills the empty space. | | `padding` | Object | Padding. | **button** Shows a button that calls a URL of yours, see Buttons. | Property | Type | Description | |---|---|---| | `text` | String | **required**. Label of the button. | | `url` | String | **required**. URL to call when the button is tapped. | | `style` | String | `filled`, `outline`, `icon-primary`, `icon-secondary`. | | `color` | String | Text Color. | | `tint` | String | Background Color. | | `padding` | Object | Padding. | **refreshButton** Shows a button that refreshes the widget. | Property | Type | Description | |---|---|---| | `text` | String | Label of the button. Default: `Refresh`. | | `style` | String | `filled`, `outline`, `icon-primary`, `icon-secondary`. | | `color` | String | Text Color. | | `tint` | String | Background Color. | | `padding` | Object | Padding. | **lastUpdated** Shows the time the widget was last refreshed, as `HH:mm:ss`. | Property | Type | Description | |---|---|---| | `color` | String | Text Color. | | `background` | String | Background Color. | | `fontSize` | Number or String | Font size in sp. Default: `14`. | | `fontWeight` | String | `normal`, `medium`, `bold`. Default: `normal`. | | `italic` | Boolean | Italic text. | | `padding` | Object | Padding. | > Android has no `hstack`, `vstack`, `zstack` or `controlWidget`, and `text` has no `underline` or `strikethrough`. Use `row`, `column` and `box` instead of the stacks. ### Values #### Padding ```json "padding": {"top": 4, "bottom": 4, "left": 8, "right": 8} ``` Each side is optional. #### Color | | Values | |---|---| | Hex | `#00FF00`, `#aa00FF`. | | Named, both platforms | `black`, `blue`, `brown`, `cyan`, `gray`, `green`, `indigo`, `mint`, `orange`, `pink`, `purple`, `red`, `teal`, `white`, `yellow`, `magenta`, `aqua`, `darkgray`, `fuchsia`, `lightgray`, `lime`, `maroon`, `navy`, `olive`, `silver`, `clear`. | | Named, iOS and watchOS only | `primary` (black in light mode, white in dark mode), `secondary`, `accentColor` (blue). | | Named, Android only | Colors of the system theme: `primary`, `onprimary`, `primarycontainer`, `onprimarycontainer`, `secondary`, `onsecondary`, `secondarycontainer`, `onsecondarycontainer`, `tertiary`, `ontertiary`, `tertiarycontainer`, `ontertiarycontainer`, `error`, `onerror`, `errorcontainer`, `onerrorcontainer`, `background`, `onbackground`, `surface`, `onsurface`, `surfacevariant`, `onsurfacevariant`, `outline`, `inversesurface`, `inverseonsurface`, `inverseprimary`, `widgetbackground`. | | Light and dark mode | Two colors separated by a comma, light mode first: `#000000,#FFFFFF`. | | Gradient, iOS and watchOS only | Colors joined by `+`: `red+green+blue`. Can be combined with light and dark mode: `red+green,#f00+yellow`. | #### Buttons Tapping a `button` element, or a `controlWidget` control, does not open anything. The device sends a `GET` request to its `url` in the background and then refreshes the widget, so the URL can change something on the customer's side and the widget shows the result. The request comes from the user's device and carries these headers: | Header | Value | |---|---| | `X-Service-Id` | ID of the service. | | `X-Widget-Id` | ID of the widget. | | `X-Device-Id` | ID of the device. | | `X-Customer-User-Id` | The `user_id` of the user. | | `User-Agent` | `Appio-IOS-Widget-`, `Appio-Watch-Widget-` or `Appio-Android-Widget-`. | ### Complete example A widget that shows the orders of the day. The data source returns the JSON shown under Placeholders. Layout for `template_ios`: one variant for the small and medium home screen widget, one for the lock screen ```json { "variants": [ { "properties": { "version": "1.0", "supportedFamilies": [ "systemSmall", "systemMedium" ], "background": "#FFFFFF,#1C1C1E", "url": "https://example.com/orders" }, "elements": [ { "type": "vstack", "properties": { "alignment": "leading", "spacing": 4 }, "elements": [ { "type": "text", "properties": { "text": "Orders today", "fontSize": 13, "color": "secondary" } }, { "type": "text", "properties": { "text": "{{$.orders}}", "fontSize": 40, "fontWeight": "bold" } }, { "type": "text", "properties": { "text": "Revenue {{$.revenue}}", "fontSize": 13 } }, { "type": "gauge", "properties": { "value": "{{$.goal_percent}}", "tint": "green" } } ] } ] }, { "properties": { "version": "1.0", "supportedFamilies": [ "accessoryRectangular" ] }, "elements": [ { "type": "text", "properties": { "text": "{{$.orders}} orders", "fontWeight": "semibold" } } ] } ] } ``` Layout for `template_watchos` ```json { "variants": [ { "properties": { "version": "1.0", "supportedFamilies": [ "accessoryRectangular", "accessoryInline" ] }, "elements": [ { "type": "text", "properties": { "text": "{{$.orders}} orders" } } ] }, { "properties": { "version": "1.0", "supportedFamilies": [ "accessoryCircular" ] }, "elements": [ { "type": "gauge", "properties": { "value": "{{$.goal_percent}}", "style": "accessoryCircularCapacity", "currentValueLabel": "{{$.orders}}" } } ] } ] } ``` Layout for `template_android`: one variant for every size ```json { "variants": [ { "properties": { "version": "1.0", "background": "widgetbackground", "url": "https://example.com/orders" }, "elements": [ { "type": "column", "properties": { "horizontalAlignment": "start", "spacing": 4, "padding": { "top": 12, "bottom": 12, "left": 12, "right": 12 } }, "elements": [ { "type": "text", "properties": { "text": "Orders today", "fontSize": 13, "color": "onsurfacevariant" } }, { "type": "text", "properties": { "text": "{{$.orders}}", "fontSize": 36, "fontWeight": "bold" } }, { "type": "text", "properties": { "text": "Revenue {{$.revenue}}", "fontSize": 13 } } ] } ] } ] } ``` Then create the three resources. Each layout is sent as a string: ```plaintext 1. POST /v1/templates {"name": "Orders", "template_ios": "", "template_android": "", "template_watchos": ""} -> {"id": "wt_…"} 2. POST /v1/datasources {"name": "Orders", "type": "url", "definition": {"url": "https://example.com/api/orders", "query_param": true}} -> {"id": "ds_…"} 3. POST /v1/widgets {"name": "Orders", "template_id": "wt_…", "data_source_id": "ds_…", "published": true} -> {"id": "wgt_…"} ``` When a device of the user `abc` refreshes the widget, Appio requests `https://example.com/api/orders?user_id=abc` and fills the placeholders with the response. Preview the data source to check what the URL returns before saving it.