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

Info

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

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 value, progress, status, gauge, stat_list). 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"
}
Info

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

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.

Info

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

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

The content object is shared across all templates. Per-template required fields are listed in the overview; everything else is optional.

FieldTypeDescription
templatestringRequired. One of value, progress, status, gauge, stat_list. 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) }. Ignored by other templates.
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.
tap_actionobjectWhole-widget tap target. 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.

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's widgetURL. 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 on systemSmall, systemMedium, and systemLarge.
  • secondary_url_action — a second button next to url_action on systemMedium / systemLarge. Ignored on small.
FieldTypeRequiredDescription
urlstringYesTarget 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.
foregroundbooleanNoWhen 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).
methodstringNoHTTP method for silent webhooks: GET (default), POST, PUT, PATCH, DELETE, HEAD. Rejected on custom-scheme URLs.
headersobjectNoHTTP headers for the silent webhook (object of string keys to string values). Total ≤1 KB. Rejected on custom-scheme URLs.
bodystringNoHTTP request body (≤1024 chars). Content-Type: application/json is set automatically when a body is present and no Content-Type header was supplied.
titlestringNoButton label rendered on url_action / secondary_url_action. Ignored by tap_action (no label is shown against the whole widget). Max 64 chars.
iconstringNoSF Symbol name shown next to title on inline buttons. Max 64 chars.
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"
      }
    }
  }'
Info

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.

SaaS dashboard widget
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": "%" }
      ]
    }
  }'
Info

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.

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.