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 accept an integration key (hlk_) with the widgets flag enabled. The flag is off by default — turn it on per-key in the iOS app's integration-keys screen. The user's own hla_ token is also accepted; see Authentication.
Each user can have a maximum of 50 widgets. Attempting to create more returns 429 Too Many Requests 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 value, progress, status, gauge, stat_list). 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"
}Re-POSTing the same slug is idempotent. The response includes X-Resource-Action: created on the first call and X-Resource-Action: updated on subsequent calls — the same convention used by 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.
Canonical Content-Type is application/merge-patch+json; application/json is also accepted. Template lives inside content — 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
The content object is shared across all templates. Per-template required fields are listed in the overview; everything else is optional.
| Field | Type | Description |
|---|---|---|
template | string | Required. One of value, progress, status, gauge, stat_list. 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) }. Ignored by other templates. |
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. |
tap_action | object | Whole-widget tap target. 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. |
Tap actions
Widget tap actions share the same shape — and the same iOS dispatcher — as Live Activity tap actions and notification actions. The widget surface exposes three slots:
tap_action— fires when the user taps anywhere on the widget body. Mapped to SwiftUI'swidgetURL. The only slot that applies to accessory families (accessoryCircular,accessoryInline) since those surfaces have no room for inline controls.url_action— primary button rendered under the widget content onsystemSmall,systemMedium, andsystemLarge.secondary_url_action— a second button next tourl_actiononsystemMedium/systemLarge. Ignored on small.
| Field | Type | Required | Description |
|---|---|---|---|
url | string | Yes | Target URL. Any scheme is accepted except javascript:, data:, file:, vbscript:. Use http(s):// for silent webhooks, custom schemes (homeassistant://, shortcuts://) to launch other apps, or your own pushward:// deep links. Max 2048 chars. |
foreground | boolean | No | When false (default) an http(s) URL fires silently from the widget extension — no app launch, no Safari. When true the URL is opened in the foreground via Safari. Ignored for custom-scheme URLs (they always launch their target app). |
method | string | No | HTTP method for silent webhooks: GET (default), POST, PUT, PATCH, DELETE, HEAD. Rejected on custom-scheme URLs. |
headers | object | No | HTTP headers for the silent webhook (object of string keys to string values). Total ≤1 KB. Rejected on custom-scheme URLs. |
body | string | No | HTTP request body (≤1024 chars). Content-Type: application/json is set automatically when a body is present and no Content-Type header was supplied. |
title | string | No | Button label rendered on url_action / secondary_url_action. Ignored by tap_action (no label is shown against the whole widget). Max 64 chars. |
icon | string | No | SF Symbol name shown next to title on inline buttons. Max 64 chars. |
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"
}
}
}'Silent webhooks fire directly from the widget extension process — the host app is never launched, the user stays on the Home Screen, and the request completes in the background. The extension's URLSession budget is ~30 seconds; design webhooks to return promptly. Lock Screen / StandBy accessory families render only tap_action (via widgetURL); silent webhooks set on url_action / secondary_url_action are still applied to system families but ignored on the accessory surfaces because there is no room for the button.
Stat list payload
The stat_list template renders up to six label : value rows — useful for SaaS dashboards (MRR / Subs / Trials), homelab summaries (Up / Down / Maintenance), or anywhere you want a compact table of figures without a chart.
curl -X POST https://api.pushward.app/widgets \
-H "Authorization: Bearer hlk_YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"slug": "saas-dashboard",
"name": "SaaS Dashboard",
"template": "stat_list",
"content": {
"label": "April",
"icon": "chart.bar.fill",
"accent_color": "indigo",
"stat_rows": [
{ "label": "MRR", "value": "$8 333", "unit": "USD" },
{ "label": "Subs", "value": "412" },
{ "label": "Trials", "value": "37" },
{ "label": "Churn", "value": "1.2", "unit": "%" }
]
}
}'Each row's label and value are strings, not numbers — pre-format on the server so the widget renders the value exactly as you intend (currency symbol, thousands separator, fixed decimals). unit is optional and rendered to the right of the value when set.
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. |