Widgets API
Register, update, list, and delete widgets via REST. Widgets are push-driven — each PATCH triggers a thin APNs payload that reloads the iOS extension's timeline.
Widget endpoints require an integration key (hlk_) with the widgets flag, which is off by default — turn it on per-key in the iOS app's integration-keys screen (see Authentication). Each user can have a maximum of 50 widgets; attempting to create more returns 429 with code: "widget.limit_exceeded".
Create Widget
/widgetsRegister a widget by slug. Idempotent — re-POSTing the same slug refreshes name / content / push_throttle in place and still returns 201.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
slug | string | Yes | URL-safe identifier (alphanumeric, hyphens, underscores; first char alphanumeric; max 128 chars). Unique per user. The widget slug namespace is independent of activity slugs. |
name | string | Yes | Human-readable name shown in the iOS widget picker. Max 256 chars. |
content | object | Yes | Initial content snapshot. Must include content.template (one of the ten template ids). Other fields are template-dependent — see Content. |
push_throttle | integer | No | Minimum seconds between APNs pushes for this widget (1 – 3600). Overrides the server's default coalesce window when you know updates will be bursty. |
curl -X POST https://api.pushward.app/widgets \
-H "Authorization: Bearer hlk_YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"slug": "cpu-load",
"name": "CPU Load",
"content": {
"template": "progress",
"value": 0.42,
"label": "load avg",
"icon": "cpu",
"accent_color": "cyan"
}
}'Response (always 201 Created):
{
"slug": "cpu-load",
"name": "CPU Load",
"content": {
"template": "progress",
"value": 0.42,
"label": "load avg",
"icon": "cpu",
"accent_color": "cyan"
},
"created_at": "2026-05-10T12:00:00Z",
"updated_at": "2026-05-10T12:00:00Z"
}The X-Resource-Action response header (created / updated) says which happened — the same convention as POST /activities.
List Widgets
/widgetsList every widget owned by the calling user.
curl https://api.pushward.app/widgets \
-H "Authorization: Bearer hlk_YOUR_TOKEN"Response (200):
{
"items": [
{
"slug": "cpu-load",
"name": "CPU Load",
"content": { "template": "progress", "value": 0.42, "label": "load avg", "icon": "cpu", "accent_color": "cyan" },
"created_at": "2026-05-10T12:00:00Z",
"updated_at": "2026-05-10T12:00:00Z"
}
]
}Get Widget
/widgets/{slug}Fetch a widget by slug. The iOS extension calls this from TimelineProvider.getTimeline whenever it needs a freshness pull.
curl https://api.pushward.app/widgets/cpu-load \
-H "Authorization: Bearer hlk_YOUR_TOKEN"Update Widget
/widgets/{slug}RFC 7396 JSON merge patch. Absent fields preserve, explicit null clears, present values overwrite. Triggers a thin push so the iOS extension reloads.
Semantics — Content-Type rules included — follow the activities merge-patch contract. Widget-specific: the template lives inside content, so change it via content.template and include any newly-required fields for the new template in the same patch (invalid combinations return 422).
curl -X PATCH https://api.pushward.app/widgets/cpu-load \
-H "Authorization: Bearer hlk_YOUR_TOKEN" \
-H "Content-Type: application/merge-patch+json" \
-d '{
"content": {
"value": 0.78,
"accent_color": "orange"
}
}'curl -X PATCH https://api.pushward.app/widgets/cpu-load \
-H "Authorization: Bearer hlk_YOUR_TOKEN" \
-H "Content-Type: application/merge-patch+json" \
-d '{"content": {"subtitle": null}}'Delete Widget
/widgets/{slug}Remove a widget. iOS instances configured to this slug become inert at the next timeline refresh.
curl -X DELETE https://api.pushward.app/widgets/cpu-load \
-H "Authorization: Bearer hlk_YOUR_TOKEN"Response: 204 No Content
Content
Per-template required fields are listed in the overview; everything else on content is optional:
| Field | Type | Description | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
template | string | Required. One of value, progress, status, gauge, stat_list, trend, countdown, battery, schedule, flow — each documented on its own page under Widgets. Determines which other fields are required for this widget. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
value | float | Primary value. Required for progress (0.0–1.0) and gauge (must fall within min_value/max_value). Must be finite. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
min_value | float | Required for gauge. Must be strictly less than max_value. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
max_value | float | Required for gauge. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
unit | string | Unit label rendered next to the value (e.g. %, °C, rpm). Max 32 chars. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
label | string | Short label rendered above or beside the value. Max 256 chars. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
subtitle | string | Secondary text shown when the widget family has room (medium / large). Max 256 chars. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
icon | string | SF Symbol name, or an MDI icon prefixed with mdi:. Max 128 chars. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
severity | string | One of info, warning, critical, success. Drives the chip colour on the status template and tints the accent on other templates when accent_color is unset. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
accent_color | string | Named colour (see Colors) or hex string. Falls back to a per-template default. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
background_color | string | Optional override for the widget background. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
text_color | string | Optional override for primary text colour. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
stat_rows | array | Required for stat_list. 1 – 6 rows. Each row is { label (≤32), value (≤32), unit? (≤16) }. value and progress render the first 3 as supporting rows on the large family; the remaining templates ignore them. Row shape, string formatting, and a full example: Stat list. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
trend | string | Optional up / down / flat annotation. Renders as an inline arrow on the rectangular (medium) value family. The server also accepts it on gauge, but the current iOS widget doesn't render the arrow there; progress, status, and stat_list ignore it. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
device_sort | object[] | Up to 2 { field, direction } keys applied to devices before they are stored, so the prefix each family renders holds the devices that matter. field is level or name, direction is asc (the default) or desc. Omit to keep the order you sent. Applies to battery; the other templates ignore it. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
tap_action | object | Whole-widget tap target — mapped to SwiftUI's widgetURL, and therefore the only slot that applies to the accessory families (accessoryCircular, accessoryInline), which have no room for inline controls. When set, overrides the default "open the PushWard app to the widget detail" behaviour. See Tap actions. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
url_action | object | Primary inline button rendered on system widget families (small/medium/large). Same shape as tap_action; the optional title and icon become the button label. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
secondary_url_action | object | Secondary button shown next to url_action on medium and large families. Ignored on small. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Coming in app version 1.6 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
subtitle_timer | object | Any template. { date (RFC 3339), style? } — renders the subtitle as a self-updating timer (style: "timer", the default, ticks like 01:23:45; "relative" renders coarse units like 2 min). A past date counts up, a future date counts down. subtitle stays the static fallback for clients that don't render timers. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
stat_rows[].timer | object | Same shape, applied to one stat_list row: the row's trailing text becomes a ticking timer. The row's value is still required and remains the static fallback. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Tap actions
Widget tap actions use the same Action object,
dispatch modes, and limits — and the same iOS dispatcher — as Live Activity tap actions and notification actions, including the best-effort
caveat on silent webhooks. The three slots — tap_action, url_action, secondary_url_action — are described in the Content table above.
Differences from the Live Activity contract on the widget surface:
- An
http(s)action fires silently by default —foregrounddefaults tofalseand no HTTP shape (method/headers/body) is needed for silent dispatch. Setforeground: trueto open the URL in Safari instead. method,headers, andbodyare rejected on custom-scheme URLs, not silently ignored.Content-Type: application/jsonis set automatically when abodyis present and noContent-Typeheader was supplied.- The widget extension's URLSession budget is ~30 seconds — design webhooks to return promptly.
- Besides third-party schemes (
homeassistant://,shortcuts://), PushWard's ownpushward://deep links work as custom-scheme targets.
curl -X PATCH https://api.pushward.app/widgets/blinds \
-H "Authorization: Bearer hlk_YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"content": {
"label": "Living Room",
"icon": "blinds.horizontal.closed",
"url_action": {
"title": "Open",
"icon": "arrow.up.to.line",
"url": "https://homeassistant.example.com/api/services/cover/open_cover",
"method": "POST",
"headers": { "Authorization": "Bearer HA_LONG_LIVED_TOKEN" },
"body": "{\"entity_id\":\"cover.blinds_living_room\"}"
},
"secondary_url_action": {
"title": "Close",
"icon": "arrow.down.to.line",
"url": "https://homeassistant.example.com/api/services/cover/close_cover",
"method": "POST",
"headers": { "Authorization": "Bearer HA_LONG_LIVED_TOKEN" },
"body": "{\"entity_id\":\"cover.blinds_living_room\"}"
},
"tap_action": {
"url": "homeassistant://navigate/lovelace/blinds"
}
}
}'This section describes a feature in an app update that's pending App Store review. It unlocks here automatically once the update is live.
Staleness (stale_after)
stale_after is a widget-level field — a sibling of slug and name, not part of content — accepted on both POST /widgets and PATCH /widgets/{slug}.
| Field | Type | Description |
|---|---|---|
stale_after | integer | Seconds after the widget's updated_at before clients render it as stale: Home Screen widgets dim and show a locale-formatted "as of" timestamp; Lock Screen (accessory) widgets keep their normal appearance. 60 – 604800 (one minute to seven days). Absent means never demoted; send null in a PATCH to clear it. |
curl -X PATCH https://api.pushward.app/widgets/cpu-load \
-H "Authorization: Bearer hlk_YOUR_TOKEN" \
-H "Content-Type: application/merge-patch+json" \
-d '{
"stale_after": 900,
"content": { "value": 0.42 }
}'A PATCH that changes nothing still re-stamps updated_at and resets the staleness clock — stale_after is a liveness signal, not a change signal. A poller that finds the value unchanged should PATCH anyway, so a widget whose producer died shows as stale instead of a confident, wrong number.
Errors
Widget errors use the same RFC 9457 Problem Details body shape and stable code matching guidance as the rest of the API.
| Status | Meaning |
|---|---|
400 | Malformed JSON or empty PATCH body. |
401 | Missing or invalid token. |
403 | Integration key does not have the widgets flag. |
404 | Widget slug not found. |
422 | Validation failure — missing required content field for the template, value out of range, non-finite number, etc. |
429 | Per-user widget cap reached (code: widget.limit_exceeded) or IP rate limit hit. |