Skip to content

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.

Info

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

POST /widgets

Register a widget by slug. Idempotent — re-POSTing the same slug refreshes name / content / push_throttle in place and still returns 201.

Request Body

FieldTypeRequiredDescription
slugstringYesURL-safe identifier (alphanumeric, hyphens, underscores; first char alphanumeric; max 128 chars). Unique per user. The widget slug namespace is independent of activity slugs.
namestringYesHuman-readable name shown in the iOS widget picker. Max 256 chars.
contentobjectYesInitial content snapshot. Must include content.template (one of the ten template ids). Other fields are template-dependent — see Content.
push_throttleintegerNoMinimum seconds between APNs pushes for this widget (1 – 3600). Overrides the server's default coalesce window when you know updates will be bursty.
Create a CPU-load widget
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

GET /widgets

List every widget owned by the calling user.

List widgets
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

GET /widgets/{slug}

Fetch a widget by slug. The iOS extension calls this from TimelineProvider.getTimeline whenever it needs a freshness pull.

Get widget
curl https://api.pushward.app/widgets/cpu-load \
  -H "Authorization: Bearer hlk_YOUR_TOKEN"

Update Widget

PATCH /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).

Update a value
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"
    }
  }'
Clear the subtitle without touching anything else
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

DELETE /widgets/{slug}

Remove a widget. iOS instances configured to this slug become inert at the next timeline refresh.

Delete widget
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:

FieldTypeDescription
templatestringRequired. 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.
valuefloatPrimary value. Required for progress (0.0–1.0) and gauge (must fall within min_value/max_value). Must be finite.
min_valuefloatRequired for gauge. Must be strictly less than max_value.
max_valuefloatRequired for gauge.
unitstringUnit label rendered next to the value (e.g. %, °C, rpm). Max 32 chars.
labelstringShort label rendered above or beside the value. Max 256 chars.
subtitlestringSecondary text shown when the widget family has room (medium / large). Max 256 chars.
iconstringSF Symbol name, or an MDI icon prefixed with mdi:. Max 128 chars.
severitystringOne 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_colorstringNamed colour (see Colors) or hex string. Falls back to a per-template default.
background_colorstringOptional override for the widget background.
text_colorstringOptional override for primary text colour.
stat_rowsarrayRequired 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.
trendstringOptional 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_sortobject[]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_actionobjectWhole-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_actionobjectPrimary 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_actionobjectSecondary button shown next to url_action on medium and large families. Ignored on small.
Coming in app version 1.6

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 — foreground defaults to false and no HTTP shape (method / headers / body) is needed for silent dispatch. Set foreground: true to open the URL in Safari instead.
  • method, headers, and body are rejected on custom-scheme URLs, not silently ignored.
  • Content-Type: application/json is set automatically when a body is present and no Content-Type header was supplied.
  • The widget extension's URLSession budget is ~30 seconds — design webhooks to return promptly.
  • Besides third-party schemes (homeassistant://, shortcuts://), PushWard's own pushward:// deep links work as custom-scheme targets.
Home Assistant blinds widget (silent POST + custom-scheme fallback)
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"
      }
    }
  }'
Coming in app version 1.6

This section describes a feature in an app update that's pending App Store review. It unlocks here automatically once the update is live.

Errors

Widget errors use the same RFC 9457 Problem Details body shape and stable code matching guidance as the rest of the API.

StatusMeaning
400Malformed JSON or empty PATCH body.
401Missing or invalid token.
403Integration key does not have the widgets flag.
404Widget slug not found.
422Validation failure — missing required content field for the template, value out of range, non-finite number, etc.
429Per-user widget cap reached (code: widget.limit_exceeded) or IP rate limit hit.