# PushWard Documentation > Full Markdown bundle of every PushWard docs page, auto-generated from the prerendered HTML at build time. Source: https://pushward.app > Generated: 2026-09-02T00:19:29.348Z --- # Getting Started Start sending push notifications to your iPhone in under 5 minutes. ## 1\. Install the iOS App Download PushWard from the App Store on your iPhone running iOS 26 or later. _Requires PushWard iOS app 1.5.0 or later._ PushWard is now a universal app, so the same App Store listing also installs on Mac, and it's a universal purchase -- buy it once and it's yours on both. β„Ή Info PushWard requires iOS 26+ for Live Activity support. ## 2\. Sign In and Get Your Key Open the app and sign in with your Apple ID. This creates your PushWard account and automatically generates a **default integration key** (`hlk_`) with full activity management access and every capability enabled β€” push [notifications](https://pushward.app/docs/notifications), [widgets](https://pushward.app/docs/widgets), and [email](https://pushward.app/docs/email) β€” so it works with every endpoint out of the box. Copy your integration key from the app's settings screen. You'll need it for all API requests. πŸ’‘ Tip For production use, create separate integration keys in the iOS app with scoped access for each service instead of sharing the default key. ## 3\. Create an Activity Activities are the things you want to track on your Lock Screen. Each activity has a unique `slug` and a display `name`. Create an activity ``` curl -X POST https://api.pushward.app/activities \ -H "Authorization: Bearer hlk_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "slug": "my-build", "name": "My Build" }' ``` The activity starts in the `ended` state. Your devices are automatically subscribed. ## 4\. Start a Live Activity Send your first push notification by updating the activity state to `ongoing`. This starts a Live Activity on your device's Lock Screen. Start the Live Activity ``` curl -X PATCH https://api.pushward.app/activities/my-build \ -H "Authorization: Bearer hlk_YOUR_KEY" \ -H "Content-Type: application/merge-patch+json" \ -d '{ "state": "ongoing", "content": { "template": "generic", "progress": 0.0, "state": "Starting...", "icon": "arrow.triangle.branch", "accent_color": "cyan" } }' ``` ## 5\. Update and End Send updates to change the progress and status text. End the activity when you're done. Update progress ``` curl -X PATCH https://api.pushward.app/activities/my-build \ -H "Authorization: Bearer hlk_YOUR_KEY" \ -H "Content-Type: application/merge-patch+json" \ -d '{ "state": "ongoing", "content": { "template": "generic", "progress": 0.75, "state": "Running tests...", "icon": "arrow.triangle.branch", "accent_color": "cyan" } }' ``` End the Live Activity ``` curl -X PATCH https://api.pushward.app/activities/my-build \ -H "Authorization: Bearer hlk_YOUR_KEY" \ -H "Content-Type: application/merge-patch+json" \ -d '{ "state": "ended", "content": { "template": "generic", "progress": 1.0, "state": "Complete", "icon": "checkmark.circle.fill", "accent_color": "green" } }' ``` πŸ’‘ Tip After ending, the Live Activity remains visible on the Lock Screen for up to 4 hours (iOS behavior). The activity returns to `ended` state and can be started again. --- # Live Activities A Live Activity is an iOS feature that shows live, glanceable information right on the Lock Screen, in the Dynamic Island, and in StandBy β€” without the recipient ever opening an app. PushWard turns a single HTTP request into one. Unlike a regular push notification β€” which appears once and then slips into the notification list β€” a Live Activity stays pinned and updates in real time, then ends and clears itself when the task finishes. You create, update, and end activities entirely through PushWard's REST API, choosing one of ten templates, each with a layout tuned to a different job. ## Templates [ ### Generic Track any progress with a customizable bar, state label, and countdown timer. ![](https://pushward.app/_app/immutable/assets/bambu-nolock-poster.BIPdx4kI.webp) ](https://pushward.app/docs/live-activities/generic)[ ### Countdown Auto-managed timer with server-side warning and completion pushes. ![](https://pushward.app/_app/immutable/assets/timer-nolock-poster.CXzZFg_b.webp) ](https://pushward.app/docs/live-activities/countdown)[ ### Steps CI/CD step indicator with matrix job support and stage tracking. ![](https://pushward.app/_app/immutable/assets/github-nolock-poster.DYx9e9-e.webp) ](https://pushward.app/docs/live-activities/steps)[ ### Alert Severity-based alerts with deep links to dashboards and alert rules. ![](https://pushward.app/_app/immutable/assets/alert-nolock-poster.CkR84BUu.webp) ](https://pushward.app/docs/live-activities/alert)[ ### Gauge Monitor values within a range with auto-calculated progress and min/max labels. ![](https://pushward.app/_app/immutable/assets/gauge-nolock-poster.BQjhsviD.webp) ](https://pushward.app/docs/live-activities/gauge)[ ### Timeline Real-time sparkline chart with multi-series support, thresholds, and auto-scaling. ![](https://pushward.app/_app/immutable/assets/timeline-nolock-poster.CSdLxT0o.webp) ](https://pushward.app/docs/live-activities/timeline)[ ### Board At-a-glance dashboard of up to four labeled tiles with values, units, icons, colors, and trend arrows. ![](https://pushward.app/_app/immutable/assets/board-poster.tdV2awap.webp) ](https://pushward.app/docs/live-activities/board)[ ### Log Streaming, newest-first feed of status lines with timestamps and severity levels. ![](https://pushward.app/_app/immutable/assets/log-poster.BQPeLDfS.webp) ](https://pushward.app/docs/live-activities/log)[ ### MediaSoon Remote media-player card: cover art, a scrubber that ticks on the device, and transport buttons that fire your own webhooks. ![](https://pushward.app/_app/immutable/assets/media-nolock-poster.C1hrCngL.webp) ](https://pushward.app/docs/live-activities/media)[ ### ApprovalSoon Question card with 2-4 answer buttons β€” the server records the tapped option, pushes it to every device, and resolves the card. ![](https://pushward.app/_app/immutable/assets/approval-nolock-poster.C4pF8NBV.webp) ](https://pushward.app/docs/live-activities/approval) --- # Generic Template A flexible progress-based template suitable for builds, downloads, deployments, and any long-running task. ![Live Activity demonstration](https://pushward.app/_app/immutable/assets/bambu-poster.CnFQS_70.webp) ## Fields | Field | Type | Description | | --- | --- | --- | | `template` | string | **Required.** Must be `"generic"` | | `progress` | float | **Required.** Value between `0.0` and `1.0`; shown as a percentage in the top-right corner | | `state` | string | Short status text (e.g. "Building...", "Complete") | | `icon` | string | SF Symbol name (e.g. `"arrow.triangle.branch"`, `"washer"`) or MDI icon with `mdi:` prefix (e.g. `"mdi:washing-machine"`, `"mdi:thermometer"`) | | `remaining_time` | integer | Remaining time in seconds, shown as a countdown alongside the progress bar | | `subtitle` | string | Secondary text below the progress bar | | `accent_color` | string | Named color or hex (e.g. `"cyan"`, `"#00BCD4"`) | | `background_color` | string | Background color override | | `text_color` | string | Text color override | | `end_date` | integer | Unix timestamp (seconds) for the estimated completion. Acts as the ETA anchor for `live_progress`. See [Live progress](#live-progress). | | `live_progress` | boolean | When `true`, iOS animates the progress bar and counts the ETA down natively between pushes instead of jumping only when you send an update. **Requires `end_date`** (or `duration`); rejected with `422` without one. See [Live progress](#live-progress). | | `image_url` | string | https URL of an image shown in place of the icon -- cover art, a poster, an album sleeve. Max 2048 characters, no username or password in the URL. The _device_ downloads it, not the server. See [Artwork](#artwork). | | `image_shape` | string | How the image is framed: `poster` (2:3), `square`, or `circle`. Defaults to `square`. | | `image_thumbhash` | string | A [ThumbHash](https://evanw.github.io/thumbhash/) of the image, as padded standard-alphabet base64 (about 28 characters). Renders as a blurred stand-in until the download lands, and is the only thing that shows for an image the phone cannot reach. | Renders `url_action` and `secondary_url_action` as buttons beneath the progress bar, and treats taps on the rest of the card as `tap_action` -- see [Tap actions](https://pushward.app/docs/api/activities#tap-actions). ## Example Payload Start a generic activity ``` { "state": "ongoing", "content": { "template": "generic", "progress": 0.65, "state": "Washing", "icon": "washer", "remaining_time": 1800, "subtitle": "Cycle 2 of 3", "accent_color": "blue" } } ``` ## Live progress _Requires PushWard iOS app 1.3.3 or later._ By default the progress bar only moves when you push an update, so a slow job looks frozen between pushes. Set `live_progress: true` together with an `end_date` (the estimated completion, as a Unix timestamp) and iOS animates the bar and counts the ETA down **natively on the device** between your pushes β€” smooth motion without spending extra push budget. As with the countdown template, you can send `duration` (e.g. `"10m"` or seconds) instead of `end_date` and the server computes `end_date = now + duration` for you. - iOS interpolates from the last pushed `progress` toward completion at `end_date`, so the bar glides instead of stepping. - Each push re-anchors the animation: send a fresh `progress` and, if the estimate changed, a new `end_date`. - If the job stalls and the ETA passes without an update, the bar **freezes at the last pushed `progress`** rather than running to 100% on its own β€” so a stuck task never looks finished. - Fields you omit carry forward ([update semantics](https://pushward.app/docs/api/activities#merge-patch)): on an update that finishes the job while the activity stays ongoing, clear the countdown explicitly with `"end_date": null, "live_progress": null`, or the finished card keeps counting toward an ETA that no longer means anything. Ending the activity clears the anchors for you. Download with native live progress ``` { "state": "ongoing", "content": { "template": "generic", "progress": 0.4, "state": "Downloading", "icon": "arrow.down.circle", "subtitle": "ubuntu-24.04.iso", "end_date": 1750003600, "live_progress": true, "accent_color": "cyan" } } ``` _Requires PushWard iOS app 1.7.0 or later._ ## Artwork Send `image_url` and the icon slot becomes an image slot: cover art for the episode that is playing, the poster of the film being downloaded, the album sleeve for the track. Only the generic, [steps](https://pushward.app/docs/live-activities/steps) and [media](https://pushward.app/docs/live-activities/media) templates have an image slot. Sending any of the three image fields on another template is rejected with `422` rather than ignored, so you never end up waiting for artwork that was never going to render. ### The server never fetches the image The URL travels on the push and the _phone_ downloads it, which has two consequences worth planning around. The host has to be reachable from the open internet over https, and the file has to be small -- the device stops at 2 MB. A poster at 400x600 is well inside that; a 4K still is not. Format is whatever the system image decoder reads, which is wider than it sounds -- JPEG, PNG, HEIC, GIF, WebP and more -- provided the response is served with an `image/*` content type. (That is a separate question from what the Relay's ThumbHash encoder can read; see [Poster artwork](https://pushward.app/docs/integrations/relay#artwork).) A private _address_ never loads: iOS refuses to open a connection to a `192.168.x.x` host, or anything on Tailscale (`100.64/10`), because activities can be shared view-only and a URL on one is otherwise a way to point somebody else's phone at your LAN. A private _name_ like `https://jellyfin.local/...` resolves normally, so it loads wherever it resolves and the certificate is valid -- which in practice means on your own network, and nowhere else. Either way, send `image_thumbhash`: it is what viewers outside the network see, and it is the only tier that always renders. ### ThumbHash, the fallback that always works A [ThumbHash](https://evanw.github.io/thumbhash/) is roughly 25 bytes describing a blurred version of an image: its aspect ratio, average color, and a handful of DCT coefficients. Base64-encoded it is about 28 characters, small enough to ride inside the push payload itself. iOS draws it the instant the activity updates, then swaps in the full image once the download finishes. It is worth sending even when the URL is public. The widget process that draws a Live Activity has no network access at all, so on a cold cache -- a fresh activity, or the first render after a reboot -- the ThumbHash is the only thing on screen until the app gets a chance to warm the cache. Most languages have a ThumbHash library; compute it once where the source image already is. ### Shapes `image_shape` picks the frame: `poster` for 2:3 cover art, `square` (the default), or `circle`. A poster is the only shape that changes the layout -- on the Lock Screen card it widens the leading column and grows downward, which is the point of asking for one. It also renders a neutral accent-tinted card when there is no image and no hash yet, so a poster row never looks like a missing element. In the Dynamic Island a poster grows downward instead of sideways when expanded, and is cropped square in the compact pill, whose geometry is fixed. Now playing, with cover art and its ThumbHash ``` { "state": "ongoing", "content": { "template": "generic", "progress": 0.35, "state": "Playing", "subtitle": "S02E04 - The Constant", "icon": "play.fill", "image_url": "https://images.example.com/lost/s02e04.jpg", "image_shape": "poster", "image_thumbhash": "GYpSpSh4eHt4eHh4eHh4eHh4iIeH", "accent_color": "indigo" } } ``` ### Updating the artwork - Changing any of the three fields sends a high-priority push instead of a coalesced one. Swapping the poster mid-activity (next episode, next track) is the most visible change this template can make, and holding it behind the update cooldown would leave the old artwork on screen. - Artwork carries forward across updates; clear it with `"image_url": null`. - If a `PATCH` switches the activity to a template with no image slot, the server drops the image fields you _inherited_ from the stored content rather than failing the update -- you cannot be held responsible for keys you never sent. Fields sent in that same patch are still a `422`: switching to `alert` while also sending `image_url` is a contradiction only you can resolve. - Widgets and push notifications have no equivalent. The closest thing a notification has is `icon_url`, which replaces the source avatar rather than filling an artwork slot. See [Notifications](https://pushward.app/docs/notifications/basics). Several [Relay](https://pushward.app/docs/integrations/relay) providers do all of this for you: Jellyfin, Radarr, Sonarr and Overseerr attach the poster and its ThumbHash to the activities they create. A media server on your own network needs one setting on a self-hosted relay -- see [Poster artwork](https://pushward.app/docs/integrations/relay#artwork). --- # Countdown Template A timer with server-managed lifecycle: send one start push and the server fires the warning and completion updates itself. ![Live Activity demonstration](https://pushward.app/_app/immutable/assets/timer-poster.Cfb8TqW1.webp) ## Fields | Field | Type | Description | | --- | --- | --- | | `template` | string | **Required.** Must be `"countdown"` | | `progress` | float | **Required.** Initial progress (usually `0.0`) | | `duration` | string | **Required** (or `end_date`). Server-side convenience β€” converted to `end_date` before reaching the device. Accepts: `"30s"`, `"5m"`, `"1h30m"`, or plain seconds (`"1500"`) | | `end_date` | integer | **Required** (or `duration`). Unix timestamp when the countdown expires | | `start_date` | integer | Unix timestamp when the countdown began. Must be less than `end_date`. Used to compute progress on-device. | | `warning_threshold` | integer | Seconds before `end_date` to send a warning push (accent turns orange) | | `completion_message` | string | Text shown when countdown expires (default: "Completed") | | `state` | string | Status text (e.g. "Baking", "Printing") | | `icon` | string | SF Symbol name or MDI icon with `mdi:` prefix (e.g. `"mdi:timer-outline"`) | | `subtitle` | string | Secondary text | | `accent_color` | string | Named color or hex | | `background_color` | string | Background color override | | `text_color` | string | Text color override | | `alarm` | boolean | Opt-in: schedule an iOS AlarmKit alarm at `end_date` that rings through silent mode and Focus, with built-in Dismiss and Snooze. iOS 26+ only. Persists across partial updates until cleared with `alarm: null` or a transition to `ended` β€” see [Alarms](https://pushward.app/docs/api/activities#alarm). | | `snooze_seconds` | integer | How long the alarm's **Snooze** button defers the alarm, in seconds (60–3600). Defaults to `300` (5Β minutes). | Accepts `url_action`, `secondary_url_action`, and `tap_action` β€” `tap_action` is useful for deep-linking back into the timer's source app while it runs. See [Tap actions](https://pushward.app/docs/api/activities#tap-actions). ## Example Payload Start a countdown ``` { "state": "ongoing", "content": { "template": "countdown", "progress": 0.0, "state": "Baking", "icon": "flame", "subtitle": "25 min timer", "duration": "25m", "warning_threshold": 60, "completion_message": "Done baking!", "accent_color": "orange", "alarm": true } } ``` ## Auto-Managed Lifecycle After the start push the server manages the remaining lifecycle, in this order; the timer display itself is computed on-device from `start_date` and `end_date`, so it counts down in real time without additional pushes. | Phase | Trigger | What the server sends | | --- | --- | --- | | Start | Your push | Nothing -- your update sets `ended -> ongoing` with the countdown content | | Warning | `warning_threshold` seconds remain | An update with `accent_color: "orange"`, automatically | | Completion | `end_date` reached | An end push with `completion_message` and `progress: 1.0` | --- # Steps Template A step-based template designed for CI/CD pipelines and multi-stage workflows. Visualizes parallel jobs with a matrix indicator. ![Live Activity demonstration](https://pushward.app/_app/immutable/assets/steps-sampler-poster.COEPOYNU.webp) ## Fields | Field | Type | Description | | --- | --- | --- | | `template` | string | **Required.** Must be `"steps"` | | `progress` | float | **Required.** Value between `0.0` and `1.0` | | `current_step` | integer | **Required.** The step currently running, counting from 1 (so `1` is the first entry of `step_labels`); `0` means nothing has started yet. Shown in the header as `current_step/total_steps`. | | `total_steps` | integer | **Required.** Total number of jobs in the workflow | | `step_rows` | integer\[\] | Parallel jobs per step (e.g. `[1,1,3,1]` for a 3-job matrix at step 3). Length must equal `total_steps`. Each value must be between 1 and 10. | | `step_labels` | string\[\] | Optional labels for each step (e.g. `["Build","Test","Deploy"]`). When provided and `total_steps` is 6 or fewer, labels appear below each segment bar. Length must equal `total_steps`. Each label max 32 characters. | | `state` | string | Current job name (e.g. "Build Container Image") | | `icon` | string | SF Symbol name or MDI icon with `mdi:` prefix (e.g. `"mdi:source-branch"`) | | `subtitle` | string | Context subtitle (e.g. "repo / CI/CD") | | `accent_color` | string | Named color or hex | | `url` | string | Primary URL -- tappable button on the Live Activity (e.g. link to workflow run) | | `secondary_url` | string | Secondary URL -- second tappable button (e.g. link to commit) | | `background_color` | string | Background color override | | `text_color` | string | Text color override | | `step_weights` | number\[\] | Gives each step a relative width. On its own it renders a single row of proportional segments (e.g. `[1, 2, 1]` makes the middle step twice as wide as its neighbours). Combined with `step_rows` it widths the matrix columns while each column keeps its fan-out. Length must equal `total_steps`. See [Segmented layout](#segmented). | | `step_colors` | string\[\] | Per-step colors for the segmented layout (named color or hex). Length must equal `total_steps`. An empty entry (`""`) falls back to `accent_color` for that segment, so you can tint just a few steps. | | `live_progress` | boolean | When `true`, iOS animates the **current step** toward completion and counts down an ETA, natively, between your pushes. Requires `end_date` (or `duration`); rejected with `422` without one. See [Live progress](#live-progress). | | `duration` | integer | string | How long the **current step** takes: seconds (`5400`) or a duration string (`"90m"`, `"1h30m"`). Sets `start_date` to now and `end_date` to now + duration, which is how you re-anchor the animation on each step change. | | `end_date` | integer | Unix timestamp when the **current step** is expected to finish -- not the end of the whole run. Wins over `duration` when both are sent. | | `start_date` | integer | Unix timestamp when the current step began. Set for you by `duration`; the bar fills across `start_date` to `end_date`. | | `image_url` | string | https URL of an image shown in place of the icon. Max 2048 characters, no username or password in the URL. The _device_ downloads it, not the server, so the host has to be public. See [Artwork](#artwork). | | `image_shape` | string | How the image is framed: `poster` (2:3), `square`, or `circle`. Defaults to `square`. | | `image_thumbhash` | string | A ThumbHash of the image, as padded standard-alphabet base64 (about 28 characters). Rides on the push itself and renders as a blurred stand-in until the download lands. | _Requires PushWard iOS app 1.9.2 or later._ The subtitle renders under the activity name on the Lock Screen card and in the expanded Dynamic Island; the Apple Watch card shows it in place of the name. Builds before 1.9.2 show it only on the Apple Watch. Renders `url_action` and `secondary_url_action` as buttons under the segment bar and background taps as `tap_action`; the legacy `url` / `secondary_url` strings still work. See [Tap actions](https://pushward.app/docs/api/activities#tap-actions). ### Changing `total_steps` on an existing activity Updates are merge-patches -- an array you leave out of a `PATCH` keeps its stored value ([update semantics](https://pushward.app/docs/api/activities#merge-patch)). When you reuse one slug across runs and the new run changes `total_steps`, any carried-over array that no longer matches it is dropped for you rather than failing the update; re-send the ones you want, and the rest come back as the plain equal-width matrix. An array you _do_ send still has to match: a length that disagrees with `total_steps` in the same payload is rejected with `422`. ## Example Payload Steps step 3 of 8 ``` { "state": "ongoing", "content": { "template": "steps", "progress": 0.375, "state": "Build Container Image", "icon": "arrow.triangle.branch", "subtitle": "pushward-server / CI/CD", "current_step": 2, "total_steps": 8, "step_rows": [1, 1, 3, 1, 1, 2, 1, 1], "step_labels": ["Lint", "Build", "Test", "Scan", "Publish", "Deploy", "Verify", "Notify"], "accent_color": "green" } } ``` ## Step Rows The `step_rows` array defines how many parallel jobs exist at each step, creating a matrix visualization -- the [GitHub Actions](https://pushward.app/docs/integrations/github-actions) bridge maps workflow jobs to steps this way: ``` // [1, 1, 3, 1, 1, 2, 1, 1] // Step 1: ● (1 job) // Step 2: ● (1 job) // Step 3: ● ● ● (3 parallel jobs) // Step 4: ● (1 job) // Step 5: ● (1 job) // Step 6: ● ● (2 parallel jobs) // Step 7: ● (1 job) // Step 8: ● (1 job) ``` πŸ’‘ Tip If `step_rows` is omitted, every step is treated as a single job. The counter still shows `current_step/total_steps`. ## Segmented layout _Requires PushWard iOS app 1.3.3 or later._ Set `step_weights` to render the steps as a **single row of proportional segments** instead of the equal-width matrix. Each weight is that step's relative share of the bar width, so `[1, 2, 1]` gives a narrow-wide-narrow layout. This suits a linear process whose stages take different amounts of time (a wash cycle, a multi-part checkout) more than a CI matrix. In this layout the shared `progress` field fills the **current** segment fractionally: completed steps are filled solid, the `current_step` segment fills to `progress` (0.0-1.0), and later steps stay empty. Pair it with `step_colors` to give each stage its own tint. Dishwasher: segmented steps with per-step colors ``` { "state": "ongoing", "content": { "template": "steps", "state": "Drying", "icon": "dishwasher", "subtitle": "Eco cycle", "current_step": 3, "total_steps": 3, "progress": 0.5, "step_labels": ["Wash", "Rinse", "Dry"], "step_weights": [1, 2, 1], "step_colors": ["blue", "teal", "orange"], "accent_color": "teal" } } ``` ### Weighted matrix Send `step_weights` alongside `step_rows` to keep the parallel-jobs matrix but size each column by how long its step runs. Below, `Build` and `Test` dominate the width, `Test` fans out to three jobs, and each column is tinted by `step_colors`. CI pipeline: weighted matrix (duration-sized columns with fan-out) ``` { "state": "ongoing", "content": { "template": "steps", "state": "Test", "icon": "arrow.triangle.branch", "subtitle": "my-app / CI/CD", "current_step": 3, "total_steps": 6, "progress": 0.5, "step_labels": ["Lint", "Build", "Test", "Scan", "Publish", "Deploy"], "step_rows": [1, 1, 3, 1, 1, 1], "step_weights": [15, 180, 90, 45, 20, 60], "step_colors": ["purple", "blue", "yellow", "orange", "green", "green"], "accent_color": "green" } } ``` ## Live progress _Requires PushWard iOS app 1.3.5 or later._ If you know how long the current step takes -- a 90-minute wash cycle, a 4-minute test suite -- set `live_progress: true` and tell the server when that step ends: iOS fills the current step and counts an ETA down natively between your pushes. The shared mechanics -- how the animation anchors, how the anchors carry forward across updates, and how to clear them -- are on the [generic template](https://pushward.app/docs/live-activities/generic#live-progress). What is specific to steps: - The ETA describes the **current step**, not the whole run. Steps before it stay filled solid; steps after it stay empty. - While it is counting, the ETA takes the header and the `current_step/total_steps` counter moves down beside the step name. Leave `live_progress` off and the counter keeps the header. - It works in every layout: the matrix column, the weighted column, and the segmented bar. In a fan-out column all the parallel jobs fill together, since they share one step deadline. - If a step overruns its estimate without an update, its bar **stops at full** rather than racing ahead -- a stuck step never advances the counter on its own. - Send `duration` only when `current_step` changes -- that is what re-anchors the animation to the new step. Restamping the window on every update makes each one a high-priority push that skips update coalescing, and snaps the bar back to empty. Dishwasher: a 90-minute wash step that fills itself ``` { "state": "ongoing", "content": { "template": "steps", "state": "Heating water", "icon": "dishwasher", "subtitle": "Eco 50", "current_step": 2, "total_steps": 4, "step_labels": ["Pre-wash", "Wash", "Rinse", "Dry"], "step_weights": [1, 6, 1, 2], "live_progress": true, "duration": "90m", "accent_color": "teal" } } ``` _Requires PushWard iOS app 1.7.0 or later._ ## Artwork Steps has the same image slot as the [generic](https://pushward.app/docs/live-activities/generic) template: send `image_url` and the leading icon becomes artwork. On a step sequence that usually means the thing being processed rather than the tool doing the processing -- the film poster for a download that is grabbing, importing and renaming, the album sleeve for an import pipeline. The full rules -- reachability, the 2 MB limit, ThumbHash, shapes -- live on the [generic page](https://pushward.app/docs/live-activities/generic#artwork). Movie import pipeline with the film's poster ``` { "state": "ongoing", "content": { "template": "steps", "state": "Importing", "subtitle": "Radarr", "current_step": 3, "total_steps": 4, "step_labels": ["Grab", "Download", "Import", "Rename"], "progress": 0.6, "image_url": "https://image.example.com/posters/arrival.jpg", "image_shape": "poster", "image_thumbhash": "GYpSpSh4eHt4eHh4eHh4eHh4iIeH", "accent_color": "purple" } } ``` Radarr, Sonarr, Jellyfin and Overseerr do this automatically when you drive them through the [Relay](https://pushward.app/docs/integrations/relay). --- # Alert Template A severity-based template for monitoring alerts. Displays firing alerts with deep-link buttons to dashboards and alert rules. ![Live Activity demonstration](https://pushward.app/_app/immutable/assets/alert-poster.67WWFVCt.webp) ## Fields | Field | Type | Description | | --- | --- | --- | | `template` | string | **Required.** Must be `"alert"` | | `progress` | float | **Required.** Usually `0.0` for firing, `1.0` for resolved | | `severity` | string | **Required.** Alert severity: `"critical"`, `"warning"`, or `"info"` | | `severity_label` | string | Optional. Custom text shown in place of the Info/Warning/Critical badge | | `fired_at` | integer | Unix timestamp when the alert fired | | `url` | string | Primary URL -- tappable button on the Live Activity (e.g. link to alert rule) | | `secondary_url` | string | Secondary URL -- second tappable button (e.g. link to dashboard panel) | | `state` | string | Alert description (e.g. "CPU usage is 94.2%") | | `icon` | string | SF Symbol name (e.g. `"exclamationmark.triangle.fill"`) or MDI icon with `mdi:` prefix (e.g. `"mdi:alert-circle"`) | | `subtitle` | string | Source and target (e.g. "Grafana - nas-01") | | `accent_color` | string | Named color or hex (typically `"red"` for firing, `"green"` for resolved) | | `background_color` | string | Background color override | | `text_color` | string | Text color override | For one-tap acknowledgements, set `url_action` to a silent webhook, `secondary_url_action` with `foreground: true` to open the dashboard in Safari, and `tap_action` to a custom scheme to deep-link into your alerting tool's iOS app; the legacy `url` / `secondary_url` strings render as buttons that open in Safari. See [Tap actions](https://pushward.app/docs/api/activities#tap-actions). ## Example: Firing Alert Alert firing ``` { "state": "ongoing", "content": { "template": "alert", "progress": 0.0, "state": "CPU usage is 94.2%", "icon": "exclamationmark.triangle.fill", "subtitle": "Grafana Β· nas-01", "severity": "critical", "severity_label": "SEV1", "fired_at": 1750009000, "accent_color": "red", "url": "https://grafana.example.com/alerting/1afz29v7z/edit", "secondary_url": "https://grafana.example.com/d/abc123?viewPanel=1" } } ``` ## Example: Resolved Alert Alert resolved ``` { "state": "ended", "content": { "template": "alert", "progress": 1.0, "state": "Resolved β€” CPU usage normalized", "icon": "checkmark.circle.fill", "subtitle": "Grafana Β· nas-01", "severity": "critical", "accent_color": "green", "url": "https://grafana.example.com/alerting/1afz29v7z/edit" } } ``` The [Relay](https://pushward.app/docs/integrations/relay) forwards Grafana, Uptime Kuma and Gatus alerts into this template with deep links. --- # Gauge Template Monitor a numeric value within a range: the current reading with unit, min/max endpoints, and a progress bar the server fills in for you. ![Live Activity demonstration](https://pushward.app/_app/immutable/assets/gauge-poster.B0wl5Au_.webp) ## Fields | Field | Type | Description | | --- | --- | --- | | `template` | string | **Required.** Must be `"gauge"` | | `value` | float | **Required.** Current value within the range. The server derives `progress` from `(value - min_value) / (max_value - min_value)` β€” don't send it | | `min_value` | float | **Required.** Minimum value of the range | | `max_value` | float | **Required.** Maximum value of the range. Must be greater than `min_value` | | `unit` | string | Unit label displayed after the value (e.g. "Β°C", "%", "rpm"). Max 32 characters | | `state` | string | Status text (e.g. "Heating", "Ready") | | `icon` | string | SF Symbol name (e.g. `"thermometer.medium"`) or MDI icon with `mdi:` prefix | | `subtitle` | string | Secondary descriptive text | | `accent_color` | string | Named color or hex. Default: `orange` | | `background_color` | string | Background color override | | `text_color` | string | Text color override | On Lock Screen surfaces `url_action` and `secondary_url_action` render as buttons; the circular Dynamic Island variant has no inline buttons, so `tap_action` is the most useful field there. See [Tap actions](https://pushward.app/docs/api/activities#tap-actions). ## Example: Oven Preheating Gauge update ``` { "state": "ongoing", "content": { "template": "gauge", "value": 125, "min_value": 0, "max_value": 250, "unit": "Β°C", "state": "Heating", "icon": "thermometer.medium", "accent_color": "orange" } } ``` ## Example: Target Reached Gauge complete ``` { "state": "ended", "content": { "template": "gauge", "value": 250, "min_value": 0, "max_value": 250, "unit": "Β°C", "state": "Ready", "icon": "checkmark.circle.fill", "accent_color": "green" } } ``` ## Use Cases - **Smart home** β€” oven temperature, thermostat setpoint, humidity sensors - **3D printing** β€” hotend/bed temperature reaching target - **Infrastructure** β€” CPU temperature, disk fill percentage, memory usage - **IoT** β€” tank level, battery charge, signal strength --- # Timeline Template A real-time sparkline chart for tracking values over time. Each push sends a data point; the server accumulates history and delivers the sparkline data to iOS via push notifications. ![Live Activity demonstration](https://pushward.app/_app/immutable/assets/timeline-poster.CNIwLB3x.webp) ## Fields | Field | Type | Description | | --- | --- | --- | | `template` | string | **Required.** Must be `"timeline"` | | `value` | object | The data point(s) for this push. **Required.** A labeled map (e.g. `{\"CPU\": 72.5}` or `{\"CPU\": 72.5, \"GPU\": 45.2}`). Max 10 series, 32-char keys | | `unit` | string | Unit suffix on value display (e.g. "Β°C", "%"). Max 32 characters. Used as fallback when `units` is set | | `units` | object | Per-series unit overrides. Keys must match `value` keys; missing keys fall back to `unit`. Max 32-char values | | `scale` | string | Y-axis scaling: `"linear"` (default) or `"logarithmic"` | | `decimals` | integer | Decimal places for display. `null` = auto-detect. Range 0–10 | | `smoothing` | boolean | Curve interpolation between points. Default: `false` (straight segments) | | `thresholds` | array | Up to 5 horizontal reference lines. Each has `value` (required), `color`, and `label` (max 12 chars) | | `state` | string | Status text (e.g. "Heating", "Monitoring") | | `icon` | string | SF Symbol name (e.g. `"chart.xyaxis.line"`) or MDI icon with `mdi:` prefix | | `accent_color` | string | Named color or hex. Default: `teal` | | `primary_series` | string | Names which series drives the headline value and the compact high/low range on iOS. Max 32 chars, and must match one of the `value` keys to take effect. When omitted (or naming an unknown key), the alphabetically-first series is used. | Each push appends a timestamped data point to the **server-managed** `history`, which never needs to be sent: a seed on the first update is honored, but once any history exists a client-supplied `history` is ignored. Up to 300 points per series are stored; APNs payloads are dynamically downsampled via LTTB to fit 4KB while preserving peaks and visual shape. Renders `url_action` and `secondary_url_action` as buttons below the sparkline; `tap_action` covers taps elsewhere on the card β€” see [Tap actions](https://pushward.app/docs/api/activities#tap-actions). ## Example: Single Value Server temperature monitoring ``` { "state": "ongoing", "content": { "template": "timeline", "value": {"Temperature": 13.7}, "unit": "Β°C", "state": "Heating", "icon": "thermometer.medium", "accent_color": "orange", "smoothing": true } } ``` ## Example: Multi-Value with Thresholds System health with mixed units ``` { "state": "ongoing", "content": { "template": "timeline", "value": { "CPU": 72.5, "GPU": 45.2, "Fan": 60.0, "SSD": 38.1 }, "unit": "%", "units": { "GPU": "Β°C", "SSD": "Β°C" }, "state": "Monitoring", "primary_series": "CPU", "thresholds": [ { "value": 80, "color": "red", "label": "Critical" }, { "value": 60, "color": "orange" } ] } } ``` _Requires PushWard iOS app 1.3.3 or later._ πŸ’‘ Tip With more than one series, set `primary_series`: the non-primary series still draw as lines behind the headline. πŸ’‘ Tip Value-only updates are delivered at low priority to conserve the iOS push budget. Changing `scale`, `smoothing`, `decimals`, `unit`, or `units` triggers high-priority delivery. ## Use Cases - **Server monitoring** β€” CPU temperature, memory usage, network throughput over time - **Smart home** β€” room temperature trends, humidity tracking, energy consumption - **3D printing** β€” hotend/bed temperature curves during a print - **CI/CD** β€” build time trends, test suite duration over consecutive runs --- # Board Template A compact dashboard of up to four labeled tiles β€” each with a value, icon, trend arrow, and its own tap target β€” for related metrics at a glance. ![Live Activity demonstration](https://pushward.app/_app/immutable/assets/board-poster.tdV2awap.webp) ## Fields | Field | Type | Description | | --- | --- | --- | | `template` | string | **Required.** Must be `"board"` | | `tiles` | array | **Required.** 1–4 tile objects (see below). Sending more than four is rejected with a validation error. Replaced wholesale on `PATCH` β€” re-send the full array and the board redraws in place ([why](https://pushward.app/docs/api/activities#merge-patch)) | | `state` | string | Status text shown beside the activity `name` (e.g. "All systems nominal") | | `icon` | string | SF Symbol name (e.g. `"server.rack"`) or MDI icon with `mdi:` prefix | | `accent_color` | string | Named color or hex used for the heading icon and tile values. Default: `teal` | | `background_color` | string | Background color override | | `text_color` | string | Text color override | ## Tile object Each entry in `tiles` is an object: | Field | Type | Description | | --- | --- | --- | | `label` | string | **Required.** Tile caption. Max 32 characters | | `value` | string | **Required.** Primary value. Max 16 characters | | `unit` | string | Unit suffix shown after the value (e.g. "%", "GB"). Max 8 characters | | `icon` | string | SF Symbol or MDI icon shown beside the label | | `color` | string | Named color or hex for this tile's value. Falls back to `accent_color` | | `trend` | string | One of `up`, `down`, `flat` β€” renders a trend arrow | | `url_action` | object | Per-tile tap target. Same shape as [tap actions](https://pushward.app/docs/api/activities#tap-actions) | Tiles share the ~4Β KB APNs payload with the rest of the activity, so keep labels and values short. Each tile can carry its own `url_action`, so one board can deep-link to several destinations β€” see [Tap actions](https://pushward.app/docs/api/activities#tap-actions). ## Example: Service Status Board Service status board ``` { "state": "ongoing", "content": { "template": "board", "state": "All systems nominal", "icon": "server.rack", "accent_color": "green", "tiles": [ { "label": "Uptime", "value": "99.98", "unit": "%", "icon": "checkmark.circle", "color": "green", "trend": "flat" }, { "label": "CPU", "value": "47", "unit": "%", "icon": "cpu", "color": "cyan", "trend": "up" }, { "label": "Memory", "value": "6.2", "unit": "GB", "color": "blue" }, { "label": "Errors", "value": "3", "unit": "/h", "icon": "exclamationmark.triangle", "color": "orange", "trend": "down", "url_action": { "url": "https://grafana.example.com/errors", "foreground": true } } ] } } ``` ## Use Cases - **Infrastructure** β€” uptime, CPU, memory, and error-rate at a glance - **Smart home** β€” room temperatures, energy use, device states - **Media servers** β€” active streams, queue depth, transcodes, library size - **Sports & finance** β€” scoreboards, portfolio tickers, KPI dashboards --- # Log Template A streaming feed of up to 20 newest-first lines, each with optional timestamp and severity. The server keeps a scroll-back backlog you can fetch in-app, while the Live Activity always shows the most recent lines. ![Live Activity demonstration](https://pushward.app/_app/immutable/assets/log-poster.BQPeLDfS.webp) ## Fields | Field | Type | Description | | --- | --- | --- | | `template` | string | **Required.** Must be `"log"` | | `lines` | array | **Required.** 1–20 line objects, **newest first** (see below). Replaced wholesale on `PATCH` β€” to stream a feed, prepend the new line and re-send the full array ([why](https://pushward.app/docs/api/activities#merge-patch)) | | `state` | string | Status text shown beside the activity name (e.g. the source name) | | `icon` | string | SF Symbol name (e.g. `"doc.text"`) or MDI icon with `mdi:` prefix | | `accent_color` | string | Named color or hex used for the heading icon. Default: `green` | | `background_color` | string | Background color override | | `text_color` | string | Text color override | β„Ή Info The heading above the feed is the activity's top-level `name`, not a content field. Set it when you create the activity. ## Line object Each entry in `lines` is an object: | Field | Type | Description | | --- | --- | --- | | `text` | string | **Required.** The log message. Max 512 characters | | `at` | integer | Unix timestamp (seconds) shown as the line's time | | `level` | string | One of `info`, `warn`, `error` β€” tints the line and its marker | β„Ή Info The Live Activity always shows the most recent lines that fit the ~4Β KB APNs payload. πŸ’‘ Tip The server keeps a rolling **scroll-back backlog** beyond what fits on screen. The PushWard app fetches it with `GET /activities/{slug}?include=log_backlog` to show full history in-app β€” the field is read-only and never needs to be sent. ## Example: Streaming Deploy Log Streaming deploy log ``` { "state": "ongoing", "content": { "template": "log", "state": "pushward-server", "icon": "doc.text", "accent_color": "purple", "lines": [ { "text": "Rollout complete β€” 3/3 pods healthy", "at": 1718990000, "level": "info" }, { "text": "Image pull slow on node arm-2", "at": 1718989950, "level": "warn" }, { "text": "Applied manifest revision d86246c", "at": 1718989930, "level": "info" }, { "text": "Sync started by webhook", "at": 1718989900, "level": "info" } ] } } ``` ## Use Cases - **CI/CD** β€” live deploy and pipeline logs - **Home automation** β€” event feeds (motion, doors, presence) - **Monitoring** β€” rolling alert and incident streams - **Self-hosting** β€” download/import progress, backup steps, sync activity --- # Media Template A Now Playing-style media card: cover art, a live scrubber, and transport buttons that fire your webhooks β€” control any player from the Lock Screen. _Requires PushWard iOS app 1.9.0 or later._ PushWard never talks to the player: you send the state, the phone renders it, a tap arrives at your webhook, and your next push confirms what happened. β„Ή Info Builds older than 1.9.0 fall back to the generic layout with a plain progress bar (the server mirrors `position_seconds / duration_seconds` into `progress` for exactly that case). ## Fields The activity `name` is the _source_ ("Living Room", "Kitchen TV") and stays on the card in small type; `media_title` is the big line. | Field | Type | Description | | --- | --- | --- | | `template` | string | **Required.** Must be `"media"` | | `media_title` | string | Track, episode or programme title. Max 128 characters | | `subtitle` | string | Artist, show, album or channel -- the second line under the title | | `playback_state` | string | One of `playing`, `paused`, `stopped`, `buffering`. Default: `paused`. Only `playing` makes the bar tick; the play/pause glyph follows it too | | `position_seconds` | float | Playback position at the moment you sampled it. `>= 0` | | `duration_seconds` | float | Total length. `> 0`, at most 604800 (7 days). **Omit it for live or indeterminate media** (radio, a stream): no bar is drawn and the elapsed clock still counts up | | `position_at` | integer | Unix timestamp (seconds) at which `position_seconds` was sampled. Defaults to the server's receive time, which is what you want when you push straight from the player. May be at most 300 s in the future. **Re-send `position_seconds` on every play/pause transition** so the device re-anchors instead of extrapolating from a stale sample | | `volume` | float | `0.0`\-`1.0`. Draws a thin volume bar between the volume buttons | | `favorite` | boolean | `true` fills the heart. Toggle it back on your next push after the favorite webhook lands | | `controls` | object | The transport buttons -- see [Controls](#controls). All slots optional; no slots, no buttons | | `image_url`, `image_shape`, `image_thumbhash` | string | Cover art. The same trio as the [generic template](https://pushward.app/docs/live-activities/generic#artwork); the default shape is `square`. The device downloads the image, the server never does -- send the ThumbHash so the card has something to show the instant it appears | | `icon` | string | SF Symbol drawn where the art would be when there is none. Default: `music.note` | | `accent_color` | string | Named color or hex for the icon and the filled heart. Default: `pink` | | `background_color`, `text_color` | string | Color overrides | Sending any media field on another template is rejected with `422` rather than ignored, and switching an activity from `media` to another template clears them, so no card ends up carrying controls it cannot draw. `url_action` and `secondary_url_action` are accepted for compatibility but **not rendered** on the media card -- the transport row takes the button space; `tap_action` (tapping the card background) still works. ## Controls `controls` is an object of named slots. Each slot is an [Action](https://pushward.app/docs/api/activities#tap-actions) object -- the same shape as `url_action`, with the same limits on `headers` and `body`. | Slot | Where it draws | Notes | | --- | --- | --- | | `previous` | Transport row | Skip back | | `play_pause` | Transport row (large, centre) | One toggle endpoint. The glyph shows pause while `playback_state` is `playing` and play otherwise | | `play`, `pause` | Transport row (same button) | Separate endpoints instead of a toggle. iOS picks `pause` while playing and `play` otherwise, and falls back to `play_pause` when the matching one is missing. Send whichever your player exposes; you can send all three | | `next` | Transport row | Skip forward | | `stop` | Transport row (small, trailing) | | | `favorite` | Transport row (small, leading) | Heart; filled when `favorite: true` | | `volume_down`, `volume_up` | Volume row | Speaker glyphs either side of the `volume` bar | | `extra` | Extras row | Array of up to **3** custom buttons (shuffle, repeat, a source switch). `icon` is **required** on each; `title` is the accessibility label. Replaced wholesale on `PATCH`, like every array field -- re-send the full array | ### What a tap does Controls follow the [standard tap rules](https://pushward.app/docs/api/activities#silent-only) -- an `http(s)` URL is a silent webhook, a custom scheme opens that app, and a control is never allowed to open the browser: a transport button that opens Safari is not a transport button. Specific to this template: - Play/pause flips optimistically on the device the moment it is tapped, then your next push is the truth. Previous, next and stop give no pending visual -- the tap fires the webhook, and your next push updating the card is the confirmation. - Buttons exist on the Lock Screen and in the expanded Dynamic Island. The compact and minimal Island and the Apple Watch card are tap-to-open, except that the Watch shows one play/pause button. Keep `headers` and `body` small -- the whole activity has to fit the 4 KB APNs payload (see [Payload budget](#budget)). ## How the bar ticks The device does not poll you. From `position_seconds`, `position_at` and `duration_seconds` it derives a start instant and, while `playback_state` is `playing`, animates the scrubber and both clocks (elapsed on the left, `-remaining` on the right) natively -- no update quota spent while a track plays. - `playing` with a duration: the bar and clocks run from `position_at - position_seconds` until the end. Past the end they freeze at the end; they never wrap. - `playing` without a duration: no bar, the elapsed clock counts up. - `paused`, `stopped`, `buffering`: the bar and clocks freeze at `position_seconds`. - An activity the server has marked stale (see `stale_ttl`) freezes too, so a producer that died does not keep "playing". - Ending the activity forces the wire state to `stopped`: an ended card never ticks. Position-only patches (`position_seconds` / `position_at`) go out at low push priority and coalesce; anything else on the card -- title, state, duration, volume, favorite, controls -- is a structural change and goes out at high priority. ## Updates are merge patches A `PATCH` merges onto the stored content ([update semantics](https://pushward.app/docs/api/activities#merge-patch)); two things are specific to `controls`: - The `controls` object **deep-merges**: sending `{"controls": {"stop": null}}` removes the stop button and leaves the others alone; sending `{"controls": {"next": {"url": "..."}}}` replaces only `next`. - Re-sending a slot with a new `url` and no `method` re-derives the method (`POST` for `http(s)`): an explicit method from an earlier push is not remembered across a repoint, so send it again if you need something other than the default. `title` is ignored on the fixed slots -- the glyph is the label -- only `extra` buttons use `title` and `icon`. ## Payload budget The full example below is about 1.3 KB; eight controls each carrying a 40-character bearer header and a small body come to about 2.2 KB, well inside the 4 KB APNs limit. If a payload still does not fit, the server drops _whole_ controls in this order until it does -- `extra`, then `volume_down` + `volume_up`, `favorite`, `stop`, then `image_thumbhash`, `image_url`, then `previous` + `next` -- and stops at the title, state, position and play slots. **Headers and bodies are never stripped from a control that survives**: a button that fires without its credentials and gets a 401 is worse than no button. What you `GET` back is always the full stored content; shedding only affects the push. ## Surfaces - **Lock Screen** -- art, title, subtitle, source, scrubber with both clocks, the transport row, then the volume row and extras when there is room (the extras row is hidden first, then the volume row, when the card would exceed the height iOS allows). - **Dynamic Island, expanded** -- art leading, title and subtitle centred, elapsed trailing, scrubber and a compact transport row below. Buttons work here. - **Dynamic Island, compact** -- art or icon leading; while playing the elapsed clock ticks trailing, otherwise a state glyph. Tap to open. - **Apple Watch** -- art, title and one play/pause button. Mac and the in-app cards use the same layout as the Lock Screen. ## Example: A Home Assistant player Create the activity once, then push the player state. Every control here points at a Home Assistant webhook automation; the same shape works for anything that accepts an HTTP request. Create the player activity ``` curl -X POST https://api.pushward.app/activities \ -H "Authorization: Bearer hlk_YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "slug": "living-room-player", "name": "Living Room" }' ``` Push the player state with controls ``` curl -X PATCH https://api.pushward.app/activities/living-room-player \ -H "Authorization: Bearer hlk_YOUR_TOKEN" \ -H "Content-Type: application/merge-patch+json" \ -d '{ "state": "ongoing", "content": { "template": "media", "media_title": "Snooze", "subtitle": "SZA", "playback_state": "playing", "position_seconds": 47.5, "duration_seconds": 214, "volume": 0.35, "favorite": true, "image_url": "https://cdn.example.com/art/snooze.jpg", "image_shape": "square", "image_thumbhash": "m8QNJRZ3d3B4aIh4iIiHd4iPjvcX", "controls": { "previous": { "url": "https://ha.example/api/webhook/pw-prev" }, "play_pause": { "url": "https://ha.example/api/webhook/pw-toggle" }, "next": { "url": "https://ha.example/api/webhook/pw-next" }, "stop": { "url": "https://ha.example/api/webhook/pw-stop" }, "favorite": { "url": "https://ha.example/api/webhook/pw-fav" }, "volume_down": { "url": "https://ha.example/api/webhook/pw-vol-down" }, "volume_up": { "url": "https://ha.example/api/webhook/pw-vol-up" }, "extra": [ { "url": "https://ha.example/api/webhook/pw-shuffle", "icon": "shuffle", "title": "Shuffle" } ] } } }' ``` Then, as the player reports back (each one a merge patch onto the same activity): Follow-up patches: pause, resume, next track, end ``` { "content": { "playback_state": "paused", "position_seconds": 61.2 } } { "content": { "playback_state": "playing", "position_seconds": 61.2 } } { "content": { "media_title": "Kill Bill", "position_seconds": 0, "duration_seconds": 153, "favorite": false } } { "state": "ended" } ``` The one time `position_at` is worth sending is to backdate a sample you took a moment ago -- a poll that ran a few seconds before the push. The device starts the bar from that instant instead of from receipt: Backdating a position sample ``` { "content": { "position_seconds": 61.2, "position_at": 1755500000 } } ``` ## Use Cases - **Multi-room audio** -- one activity per zone, buttons steer Sonos, HEOS or Music Assistant through Home Assistant - **TVs and media servers** -- a Jellyfin or Plex session with cover art, pause and skip from the Lock Screen - **Radio and streams** -- no duration, the elapsed clock counts up, favorite bookmarks the show --- # Approval Template A question card with 2-4 answer buttons that fire your webhook or record the answer server-side β€” built for agents, deploy gates, and home automations. _Requires PushWard iOS app 1.11.0 or later._ The question rides `state`; the buttons are `options` -- each one either fires **your** webhook directly from the device, or is recorded by the server into the read-only `answer` field. The second form is what lets an agent script, a CI job or a Home Assistant automation block on a human decision without exposing any endpoint to the internet. β„Ή Info Builds older than 1.11.0 fall back to the generic layout with the first two options as working buttons. ## Fields The activity `name` is the card title and stays short; `state` is the question, two lines at most before it truncates. When `details` rows are present they take the space the question had on the Lock Screen, so make the `name` itself interrogative ("Send follow-up to Brightlane?") -- the question still shows in the expanded Dynamic Island and in-app. | Field | Type | Description | | --- | --- | --- | | `template` | string | **Required.** Must be `"approval"` | | `state` | string | The question. Max 256 characters, about 80 fit in the two rendered lines | | `options` | array | **Required, 2-4 items.** The answer buttons -- see [Options](#options). Replaced wholesale on `PATCH` -- re-send the full list ([why](https://pushward.app/docs/api/activities#merge-patch)); re-sending them clears the stored `answer` and starts a new round, so one activity can carry a sequence of questions, one after another | | `source` | string | Who is asking ("Agent", "GitHub", "n8n") -- a badge on the right of the card header, and the subtitle in the expanded Dynamic Island. Max 24 characters | | `details` | array | Up to **2** label/value context rows between the header and the buttons (recipient, amount, environment). `label` max 24, `value` max 64 characters; the array replaces wholesale | | `end_date` | integer | Unix deadline. Adds a live countdown pill to the header -- the timer ticks on the device, no pushes spent -- and enables `on_expire` | | `on_expire` | string | Option id the server records when nobody has answered by `end_date`, or `"none"` to just expire. Requires `end_date`. The expired card is pushed to every device and the activity ends | | `answer` | object | **Read-only, server-owned.** `{"option", "at", "by"}` -- ignored on write, cleared when a patch re-sends `options`. `by` is `user` (someone tapped a server-recorded option) or `expired` (the deadline applied `on_expire`); `option` is `"none"` when the deadline passed with no default. Once set, the buttons give way to a resolution row naming the chosen option | | `icon` | string | SF Symbol drawn in the accent-tinted square at the head of the card | | `accent_color` | string | Named color or hex for the icon, the badges and the primary button | | `background_color`, `text_color` | string | Color overrides | Sending an approval field on another template is rejected with `422` rather than ignored, and so is the reverse: `url_action`, `secondary_url_action`, `alarm` and `snooze_seconds` are rejected on this template -- the options row _is_ the button row. `tap_action` (tapping the card background) still works. ## Options Each option is one button. Two options render as a filled-primary next to an outlined-secondary; three or four render as a row of icon-over-label tiles, which is why `icon` becomes required at that count. | Field | Type | Description | | --- | --- | --- | | `id` | string | **Required.** Stable identifier recorded in `answer.option`. Unique within `options`, max 64 characters, slug charset (`a-z A-Z 0-9 _ -`); the first character must be a letter or digit | | `title` | string | **Required.** Button label, max 24 characters -- up to four buttons share one row, keep it short | | `style` | string | `primary` (filled accent), `secondary` (outlined) or `destructive` (red). Default: the first option renders primary, the rest secondary | | `icon` | string | SF Symbol, max 64 characters. **Required when there are 3 or more options** -- they draw as icon-first tiles | | `url` | string | Webhook fired when the option is tapped. **Omit it for the server-recorded form** -- see [how the answer comes back](#answer-modes) | | `method` | string | HTTP method for the webhook. Defaults to `POST` | | `headers`, `body` | object, string | Ride on the webhook request, 1 KB each -- same limits as every other [Action](https://pushward.app/docs/api/activities#tap-actions) | ## Two ways the answer comes back Taps follow the [standard tap rules](https://pushward.app/docs/api/activities#silent-only) -- an `http(s)` URL is a silent webhook, a custom scheme opens that app, and a button is never allowed to open the browser: an answer button that opens Safari is not an answer button. The card itself never invents state: after a tap it waits for a push, and which push that is depends on whether the option carries a `url`. - **Producer webhooks** -- every option carries a `url`. The device calls your endpoint directly; your side then confirms with a `PATCH` (new question, new options) or ends the activity. The server never learns what was tapped, and a second device can fire the same webhook again -- use the server-recorded form when a repeat matters. - **Server-recorded** -- options omit `url`. The server fills in a signed answer URL when it stores the option; the first tap writes `answer`, the resolved card is pushed to every device, and the activity ends on its own a few seconds later. First answer wins -- a second device tapping later changes nothing. `dismissal_ttl` (an activity-level field) is how long the answered card lingers on the Lock Screen. Your code just polls: Poll for the answer ``` curl https://api.pushward.app/activities/approval-request \ -H "Authorization: Bearer hlk_YOUR_TOKEN" { "state": "ended", "content": { "template": "approval", "state": "Promote 1.11.0 to production?", "options": [ { "id": "promote", "title": "Promote", "style": "primary", "url": "https://api.pushward.app/activity/answer?token=...", "method": "POST" }, { "id": "hold", "title": "Hold", "style": "secondary", "url": "https://api.pushward.app/activity/answer?token=...", "method": "POST" } ], "answer": { "option": "promote", "at": 1788220800, "by": "user" } } } ``` Poll `GET /activities/{slug}` until `content.answer` is set (a few seconds between requests is plenty). Each option comes back carrying the signed answer `url` the server filled in when it stored the url-less options (the token is shortened to `...` here) and `method: "POST"`. The two forms mix freely: a "More info" option can carry a webhook while "Approve" and "Deny" stay server-recorded. ## Example: An agent asking before it acts Create the activity once, then push the question. This is the webhook form -- both options point at the agent's own endpoint: Create the approval activity ``` curl -X POST https://api.pushward.app/activities \ -H "Authorization: Bearer hlk_YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "slug": "approval-request", "name": "Approval Needed" }' ``` Ask with producer webhooks ``` curl -X PATCH https://api.pushward.app/activities/approval-request \ -H "Authorization: Bearer hlk_YOUR_TOKEN" \ -H "Content-Type: application/merge-patch+json" \ -d '{ "state": "ongoing", "content": { "template": "approval", "state": "Send the follow-up email to Brightlane?", "source": "Agent", "icon": "sparkles", "accent_color": "green", "details": [ { "label": "To", "value": "ops@brightlane.com" }, { "label": "Subject", "value": "Q3 proposal follow-up" } ], "options": [ { "id": "send", "title": "Send", "style": "primary", "url": "https://agent.example/hooks/brightlane", "body": "{\"decision\":\"send\"}" }, { "id": "deny", "title": "Deny", "style": "secondary", "url": "https://agent.example/hooks/brightlane", "body": "{\"decision\":\"deny\"}" } ] } }' ``` Drop the URLs and the same question becomes server-recorded -- nothing to host, nothing exposed; the agent polls the activity instead: Server-recorded options, no webhook ``` { "state": "ongoing", "content": { "template": "approval", "state": "Promote 1.11.0 to production?", "source": "Forgejo CI", "icon": "shippingbox.fill", "accent_color": "blue", "options": [ { "id": "promote", "title": "Promote", "style": "primary" }, { "id": "hold", "title": "Hold", "style": "secondary" } ] } } ``` Either form takes a deadline; here nobody answering within five minutes counts as "hold": Deadline with a default answer ``` { "content": { "end_date": 1788221100, "on_expire": "hold" } } ``` ## Use Cases - **Agents** -- "Should I send this?": the script blocks on a human decision - **Deploy gates** -- pipeline green, "Staging, prod, or skip?" from the Lock Screen - **Home automation** -- "Unlock the gate for the courier?" pointing at Home Assistant webhooks - **Paging** -- "Ack the incident?" with a deadline that escalates when it expires --- # Notifications Send a JSON recipe β€” title, body, and any extras β€” and PushWard delivers a real iOS push. [![Lock screen notification showing title, subtitle, and body](https://pushward.app/_app/immutable/assets/notif-basics-540.DO3TsM56.jpg) ### Basics Title, body, subtitle, and how loud it pings.](https://pushward.app/docs/notifications/basics) [![Notification with rich media attachment on the lock screen](https://pushward.app/_app/immutable/assets/notif-rich-media-540.CCWGF1Ej.jpg) ### Rich Media Attach an image, video, or audio clip β€” rendered inline.](https://pushward.app/docs/notifications/rich-media) [![Lock screen notification with image attachment and long-press action menu](https://pushward.app/_app/immutable/assets/notif-img-lock-540.pzwl4yoE.jpg) ### Actions Up to 10 buttons on long-press. Approve, deny, open, restart.](https://pushward.app/docs/notifications/actions) [![Notification rendered in iOS Communication style with a source avatar](https://pushward.app/_app/immutable/assets/notif-communication-540.DTcela5q.jpg) ### Communication Style A name and avatar so the notification feels like a real app.](https://pushward.app/docs/notifications/communication) [ #### Activity Link Deep-link a notification into an existing Live Activity. ](https://pushward.app/docs/notifications/activity-link)[ #### API Reference Full request body, endpoints, and response schemas. ](https://pushward.app/docs/notifications/api) --- # Basics Title, body, subtitle, and interruption level β€” the minimum payload for a push notification. ![Lock screen notification showing title, subtitle, and body](https://pushward.app/_app/immutable/assets/notif-basics-540.DO3TsM56.jpg) ![PushWard app detail view of the notification, showing title, subtitle, and body](https://pushward.app/_app/immutable/assets/notif-basics-view-540.AGFUQr11.jpg) ## Fields | Field | Type | Required | Description | | --- | --- | --- | --- | | `title` | string | Yes | Notification title shown in bold on the banner and lock screen | | `body` | string | Yes | Body text shown under the title | | `subtitle` | string | No | Optional subtitle shown between the title and body | | `level` | string | No | Interruption level. One of `passive`, `active` (default), `time-sensitive`, or `critical`. Controls iOS interruption behavior. | | `push` | boolean | No | If `true` (default), sends an APNs push to all of the user's devices. Set `false` to store in the inbox only. | ## Interruption levels | Level | Behavior | | --- | --- | | `passive` | Silent β€” no sound, no banner, no vibration. Lands directly in Notification Center. | | `active` | Default behavior β€” banner, sound, lock screen. | | `time-sensitive` | Bypasses Focus modes (other than Do Not Disturb) and stays on the lock screen for an hour. Use sparingly β€” Apple may rate-limit apps that overuse it. | | `critical` | Plays a sound and breaks through silent mode and Focus even when the device is muted. Requires the user to have granted Critical Alerts permission. Pair with the optional `volume` field (0.0-1.0) to set the loudness. | ## Example Send a basic notification ``` curl -X POST https://api.pushward.app/notifications \ -H "Authorization: Bearer hlk_YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "title": "Hello from PushWard", "subtitle": "Your first notification", "body": "This is a basic push notification.", "level": "active" }' ``` --- # Rich Media Attach an image, video, or audio file. iOS downloads and renders the attachment inline when the user expands the notification. ![Notification with rich media attachment on the lock screen](https://pushward.app/_app/immutable/assets/notif-rich-media-540.CCWGF1Ej.jpg) ![PushWard app detail view of the notification with an inline video player and tap link](https://pushward.app/_app/immutable/assets/notif-rich-media-view-540.Clv-7Qvf.jpg) ## The `media` object | Field | Type | Required | Description | | --- | --- | --- | --- | | `media.url` | string | Yes | HTTPS URL to the asset (max 2048 chars). HTTP is rejected. | | `media.type` | string | Yes | One of `image`, `video`, or `audio`. | The request takes a nested `media` object; responses flatten it into top-level `media_url` and `media_type` fields. ⚠ Warning Apple enforces strict size caps on notification attachments β€” image 10 MB, audio 5 MB, video 50 MB. Larger files are silently dropped by the iOS notification service extension and the notification falls back to a plain alert with no media. ## Image JPEG, PNG, GIF, and HEIC are supported. iOS shows the image inline on the lock screen and in the expanded notification. Image attachment ``` curl -X POST https://api.pushward.app/notifications \ -H "Authorization: Bearer hlk_YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "title": "Front Door", "body": "Someone is at the front door", "level": "time-sensitive", "media": { "url": "https://example.com/snapshots/doorbell.jpg", "type": "image" }, "push": true }' ``` ## Video MP4 (H.264) is the most reliable format. iOS shows a play button on the inline preview and plays the clip when the user expands the notification. Video attachment ``` curl -X POST https://api.pushward.app/notifications \ -H "Authorization: Bearer hlk_YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "title": "Garage Camera", "body": "10 seconds of motion captured", "level": "time-sensitive", "media": { "url": "https://example.com/clips/motion.mp4", "type": "video" }, "push": true }' ``` ## Audio MP3, WAV, and M4A are supported. The expanded notification shows audio playback controls directly. Audio attachment ``` curl -X POST https://api.pushward.app/notifications \ -H "Authorization: Bearer hlk_YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "title": "Voice memo", "body": "Tap to expand and play", "media": { "url": "https://example.com/voice/memo-15s.mp3", "type": "audio" }, "push": true }' ``` πŸ’‘ Tip Host media on a CDN with cache headers β€” iOS downloads the attachment on every device that receives the push, and slow downloads can cause the notification to fall back to a plain alert before the asset arrives. --- # Actions Add up to 10 buttons that appear when the user long-presses (or pulls down) the notification. Each button can foreground the app, require authentication, or be styled as destructive. ![Lock screen notification with image attachment and long-press action menu](https://pushward.app/_app/immutable/assets/notif-img-lock-540.pzwl4yoE.jpg) ![PushWard app detail view of the notification with full-size image attachment](https://pushward.app/_app/immutable/assets/notif-img-lock-view-540.B7iI3TIv.jpg) ## The `actions` array Each entry in the `actions` array is one button. Labels, icons, and URLs travel with the push, so they can change on every notification. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | Yes | Stable identifier returned to the app when the user taps. Max 64 chars. | | `title` | string | Yes | Button label shown to the user. Max 64 chars. | | `icon` | string | No | SF Symbol name (iOS 15+), e.g. `checkmark.circle`, `lock.open`. | | `url` | string | No | URL opened or dispatched when tapped. Any URL scheme is accepted except `javascript:`, `data:`, `file:`, and `vbscript:`. Use `http(s)://` for webhooks (fired silently from inside the app), `homeassistant://`, `shortcuts://`, `things://`, `tel:`, `mailto:`, etc. for deep links into other iOS apps, or your own app's custom scheme. Max 2048 chars. | | `method` | string | No | HTTP method for `http`/`https` actions: `GET` (default), `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`. Ignored for custom-scheme URLs. | | `headers` | object | No | Map of header name β†’ value for `http`/`https` actions. Example: `{"Authorization": "Bearer ..."}`. Total ≀ 1 KB. Ignored for custom-scheme URLs. | | `body` | string | No | Request body for `http`/`https` actions. ≀ 1 KB. If set without an explicit `Content-Type` header, defaults to `application/json`. Ignored for custom-scheme URLs. | | `foreground` | boolean | No | When `false` (default), tapping fires the URL silently from inside the PushWard iOS app β€” the user stays on the lock screen, no browser opens. For `http`/`https` URLs this is a background `URLSession` request using the action's `method`/`headers`/`body`; for custom schemes (e.g. `homeassistant://`) the target app is opened directly. When `true`, PushWard is brought to the foreground and the URL is opened from the app β€” only `https://` URLs are honored on this path, so use `foreground: false` for deep links into other apps. | | `destructive` | boolean | No | Render the label in red. Use for "Delete", "Deny", "Ignore" style actions. | | `authentication_required` | boolean | No | Require Face ID / Touch ID / passcode before firing the action. | | `text_input` | boolean | No | Show an inline reply field when the action is tapped. Requires a silent (`foreground: false`) `http`/`https` action. The typed text replaces `{{input}}` in `body`; if `body` has no placeholder, the reply is sent as `{"text": "..."}`. See [Reply with text](#reply-with-text) below. | | `text_input_placeholder` | string | No | Placeholder shown in the reply field. Requires `text_input`. Max 64 chars. | | `text_input_button_title` | string | No | Label of the send button next to the reply field. Defaults to the action `title`. Requires `text_input`. Max 64 chars. | _Requires PushWard iOS app 1.5.0 or later._ ## Reply with text A silent `http`/`https` action can prompt for a typed reply instead of firing right away. Set `text_input: true` β€” only valid when `foreground` is `false` β€” and PushWard shows an inline text field when the action is tapped. The reply reaches your webhook one of two ways: - If `body` contains `{{input}}`, that placeholder is replaced with the typed text. - Otherwise the reply is sent as `{"text": "..."}`. Reply to a message from the notification ``` curl -X POST https://api.pushward.app/notifications \ -H "Authorization: Bearer hlk_YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "title": "New message", "body": "Alex: are we still on for tomorrow?", "source": "chat", "actions": [ { "id": "reply", "title": "Reply", "icon": "arrowshape.turn.up.left", "url": "https://chat.example.com/api/threads/42/reply", "method": "POST", "text_input": true, "text_input_placeholder": "Write a reply", "text_input_button_title": "Send" } ] }' ``` ### Example: Overseerr request approval A new movie request lands. Three buttons: **Approve** (silent webhook), **Deny** (red, silent webhook), and **View Details** (opens the app). Each `id` and `url` is unique to the request. Overseerr request β€” approve / deny / view ``` curl -X POST https://api.pushward.app/notifications \ -H "Authorization: Bearer hlk_YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "title": "Overseerr", "body": "New request: Dune: Part Two (2024)", "source": "overseerr", "source_display_name": "Overseerr", "actions": [ { "id": "approve", "title": "Approve", "icon": "checkmark.circle", "url": "https://overseerr.example.com/api/v1/request/123/approve" }, { "id": "deny", "title": "Deny", "icon": "xmark.circle", "destructive": true, "url": "https://overseerr.example.com/api/v1/request/123/decline" }, { "id": "view", "title": "View Details", "icon": "arrow.up.right.square", "foreground": true, "url": "https://overseerr.example.com/request/123" } ] }' ``` ### Example: Home Assistant doorbell A foreground unlock that requires Face ID, a foreground "view camera," and a destructive "ignore" with no URL. Doorbell with Unlock / View / Ignore ``` curl -X POST https://api.pushward.app/notifications \ -H "Authorization: Bearer hlk_YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "title": "Front Door", "body": "Someone is at the front door", "level": "time-sensitive", "actions": [ { "id": "unlock", "title": "Unlock", "icon": "lock.open", "foreground": true, "authentication_required": true, "url": "https://homeassistant.local/unlock" }, { "id": "view", "title": "View Camera", "icon": "video", "foreground": true, "url": "https://homeassistant.local/camera" }, { "id": "ignore", "title": "Ignore", "icon": "xmark", "destructive": true } ] }' ``` ### Example: Home Assistant deep link Skip the browser entirely β€” tap **Open dashboard** and the Home Assistant app opens directly to the configured view. Custom-scheme URLs ignore `method`, `headers`, and `body`. Home Assistant β€” open the app via deep link ``` curl -X POST https://api.pushward.app/notifications \ -H "Authorization: Bearer hlk_YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "title": "Living room", "body": "Motion detected", "actions": [ { "id": "open-dashboard", "title": "Open dashboard", "icon": "house", "url": "homeassistant://navigate/lovelace/0" } ] }' ``` ### Example: POST to Home Assistant REST API With `method`, `headers`, and `body` you can call any HTTP endpoint directly β€” no webhook automation needed. This action calls Home Assistant's REST API to open a cover, with a Bearer token in the `Authorization` header and a JSON body naming the entity. Home Assistant β€” open cover via REST API ``` curl -X POST https://api.pushward.app/notifications \ -H "Authorization: Bearer hlk_YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "title": "Blinds", "body": "Open the living room blinds?", "actions": [ { "id": "open-blinds", "title": "Open", "icon": "blinds.horizontal.open", "url": "https://homeassistant.local/api/services/cover/open_cover", "method": "POST", "headers": { "Authorization": "Bearer HA_LONG_LIVED_TOKEN" }, "body": "{\"entity_id\":\"cover.blinds_living_room_left\"}" } ] }' ``` --- # Communication Style Set icon\_url to render the notification with iOS Communication Notification styling β€” a round avatar on the side, like an iMessage. Combine with thread\_id for grouping in Notification Center. ![Notification rendered in iOS Communication style with a source avatar](https://pushward.app/_app/immutable/assets/notif-communication-540.DTcela5q.jpg) ![PushWard app detail view of the Communication-style notification with sender avatar](https://pushward.app/_app/immutable/assets/notif-img-lock-view-540.B7iI3TIv.jpg) ## Source identity | Field | Type | Description | | --- | --- | --- | | `source` | string | Stable internal id β€” e.g. `sonarr`, `github-actions`, `home-assistant`. Used for filtering and analytics. | | `source_display_name` | string | The pretty version users see β€” e.g. `Sonarr`, `GitHub Actions`. Shown above the title and used to group notifications under that source in iOS settings and the inbox. | | `icon_url` | string | http or https avatar (max 2048 chars). Recommended ≀256Γ—256 and ≀100Β KB; the iOS extension rejects responses larger than 512Β KB to protect its 24Β MB memory budget. When set, iOS renders the notification in Communication Notification style β€” a round avatar floating on the side, like an iMessage. | ## Example: Home Assistant doorbell Communication-style notification ``` curl -X POST https://api.pushward.app/notifications \ -H "Authorization: Bearer hlk_YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "title": "Front Door", "subtitle": "Doorbell pressed Β· 13:45", "body": "Someone is at the front door", "level": "time-sensitive", "source": "home-assistant", "source_display_name": "Home Assistant", "thread_id": "ha-doorbell", "icon_url": "https://www.home-assistant.io/images/favicon-192x192.png", "push": true }' ``` ## Threading with `thread_id` When several notifications belong to the same "conversation," give them all the same `thread_id`. iOS stacks them as one expandable group in Notification Center β€” the user sees a single row that opens to reveal the full list. They _add up_; nothing is overwritten. Good real-world threads: - Every download for one show: `thread_id: "sonarr-the-bear"` - Every message in one chat: `thread_id: "chat-42"` - Every alert from one host: `thread_id: "alerts-prod-db-01"` Two Sonarr downloads stack as one group ``` # First episode curl -X POST https://api.pushward.app/notifications \ -H "Authorization: Bearer hlk_YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "title": "Sonarr", "body": "The Bear S03E01 downloaded", "thread_id": "sonarr-the-bear", "source": "sonarr", "source_display_name": "Sonarr", "push": true }' # Second episode β€” appears stacked under the first curl -X POST https://api.pushward.app/notifications \ -H "Authorization: Bearer hlk_YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "title": "Sonarr", "body": "The Bear S03E02 downloaded", "thread_id": "sonarr-the-bear", "source": "sonarr", "source_display_name": "Sonarr", "push": true }' ``` ## Replacement with `collapse_id` When you only care about the latest value of something, send each update with the same `collapse_id`. APNs replaces the previous notification on-device β€” the user always sees one row, and it keeps changing. Good real-world collapses: - CI build progress: `collapse_id: "build-482"` with body cycling through "running 12%" β†’ "running 84%" β†’ "passed". - Live score updates: `collapse_id: "match-arsenal-chelsea"`. - Battery / charge percent on a 3D print: `collapse_id: "printer-x1c"`. β„Ή Info `collapse_id` is a pure APNs delivery hint β€” PushWard does _not_ store it, and it's not returned in the response. Keep it under 64 characters. Build status that updates in place ``` # Build kicks off curl -X POST https://api.pushward.app/notifications \ -H "Authorization: Bearer hlk_YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "title": "Build #482", "body": "Status: running (12%)", "collapse_id": "build-482", "source": "github-actions", "source_display_name": "GitHub Actions", "push": true }' # Final status β€” overwrites the previous notification on-device curl -X POST https://api.pushward.app/notifications \ -H "Authorization: Bearer hlk_YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "title": "Build #482", "body": "Status: passed (100%)", "collapse_id": "build-482", "source": "github-actions", "source_display_name": "GitHub Actions", "push": true }' ``` ## Combine the two Use `thread_id` and `collapse_id` together to get the best of both: every build groups under one stack in Notification Center, while each individual build only ever shows its latest status. Group all builds, but keep one row per build ``` { "title": "Build #482", "body": "Status: passed (100%)", "thread_id": "ci-builds", "collapse_id": "build-482", "source": "github-actions", "source_display_name": "GitHub Actions", "push": true } ``` --- # Activity Link Tie a notification to an existing Live Activity by setting activity\_slug. Tapping the notification deep-links into that activity in the PushWard iOS app. ![Notification inbox detail view with video attachment and tap link](https://pushward.app/_app/immutable/assets/notif-video-detail-540.CbxuHWoB.jpg) ## The `activity_slug` field | Field | Type | Description | | --- | --- | --- | | `activity_slug` | string | Slug of an existing [activity](https://pushward.app/docs/api/activities). The notification appears linked to that activity in the iOS app's notification inbox; tapping it opens the activity's detail screen. | ⚠ Warning An unknown `activity_slug` is rejected with HTTP `422` before the notification is persisted. Create the activity first, then post the notification. ## Example Notification linked to a build activity ``` curl -X POST https://api.pushward.app/notifications \ -H "Authorization: Bearer hlk_YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "title": "Build complete", "body": "Workflow #1234 finished in 3m 12s", "source": "github-actions", "source_display_name": "GitHub Actions", "activity_slug": "my-build", "push": true }' ``` --- # Notifications API Send a notification to a user's inbox, with optional APNs push delivery to their devices. ## Create Notification POST `/notifications` Create an in-app notification and optionally push it to all user devices. No subscription required β€” it counts toward your monthly notification quota (free tier included) and needs the notifications capability, which your default integration key already has. ### Request Body | Field | Type | Required | Description | | --- | --- | --- | --- | | `title` | string | Yes | Notification title | | `body` | string | Yes | Notification body text | | `subtitle` | string | No | Optional subtitle | | `level` | string | No | `passive`, `active` (default), `time-sensitive`, or `critical`. Controls iOS interruption level. | | `volume` | number | No | Sound volume for critical alerts (0.0–1.0). Only used when level is `critical`. Defaults to 1.0. | | `thread_id` | string | No | Groups notifications in Notification Center | | `collapse_id` | string | No | APNs deduplication key (max 64 chars). Not stored or returned in responses. | | `source` | string | No | Source identifier (e.g. integration name) | | `source_display_name` | string | No | Human-readable source name shown in notification settings and inbox grouping | | `url` | string | No | Action URL (max 2048 chars). Any URL scheme except `javascript:`, `data:`, `file:`, or `vbscript:`; `http(s)` URLs also require a host. | | `media` | object | No | Rich media attachment. `url` (HTTPS, max 2048 chars) plus `type` (`image`, `video`, or `audio`). iOS renders inline. Apple size caps: image 10 MB, audio 5 MB, video 50 MB. | | `actions` | array | No | Up to 10 dynamic action buttons. Each: `id` (string, max 64), `title` (string, max 64), optional `url`, `foreground` (bool), `destructive` (bool), `authentication_required` (bool), `icon` (SF Symbol name). | | `icon_url` | string | No | Per-notification source avatar, shown as the Communication Notification avatar on iOS. Accepts `http` or `https` (max 2048 chars, recommended ≀256Γ—256 and ≀100 KB; the iOS extension rejects responses larger than 512 KB). | | `metadata` | object | No | Key-value string pairs (max 20 keys, key max 64 chars, value max 4096 chars, and 8 KB total across all keys and values) | | `activity_slug` | string | No | Optional link to an existing activity. An unknown slug is rejected with `422` before the notification is persisted. | | `push` | boolean | No | If `true` (default), send APNs rich alert to all user devices. Set `false` to store in the inbox only. | Example ``` curl -X POST https://api.pushward.app/notifications \ -H "Authorization: Bearer hlk_YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "title": "Deploy Complete", "body": "Successfully deployed to production", "source": "github-actions", "source_display_name": "GitHub Actions", "level": "active" }' ``` **Response (201):** ``` { "id": 42, "title": "Deploy Complete", "subtitle": "", "body": "Successfully deployed to production", "thread_id": "", "level": "active", "source": "github-actions", "source_display_name": "GitHub Actions", "url": "", "media_url": "https://example.com/img.png", "media_type": "image", "actions": [ { "id": "rerun", "title": "Re-run", "foreground": true } ], "icon_url": "", "metadata": {}, "activity_slug": "", "pushed": true, "created_at": "2026-04-24T21:00:00Z", "delivery": "all" } ``` On `POST /notifications` only, the response also includes two read-only fields describing the APNs fan-out outcome (omitted from `GET` responses): | Field | Values | Meaning | | --- | --- | --- | | `delivery` | `all`, `partial`, `none` | Whether every, some, or no devices accepted the push. | | `reason` | `no_apns_token`, `apns_rejected`, `push_disabled` | Failure mode when `delivery` is not `all` -- for a `push: false` create it is `push_disabled` with `delivery` `none`. Omitted on a fully successful push. | ## Error Responses Errors use the RFC 9457 Problem Details shape (`Content-Type: application/problem+json`) β€” see [Errors](https://pushward.app/docs/api/activities#errors) for the body shape and known `code` values. | Status | Meaning | | --- | --- | | `400` | Malformed JSON, or a field-level validation failure (media, action URL scheme, duplicate action `id`, metadata size, or `text_input` rules) | | `401` | Missing or invalid token | | `403` | Integration key lacks the `notifications` capability flag (insufficient permissions) | | `422` | Schema violation (wrong shape, `maxLength`, or `enum`), or an unknown `activity_slug` | | `429` | Per-IP rate limit (`code: rate_limit.exceeded`) or monthly notification quota exhausted (`code: quota.exceeded`) β€” pairs with `Retry-After` and `retry_after_ms`. See [Errors](https://pushward.app/docs/api/activities#errors) for the quota body shape. | | `500` | Internal server error | --- # Siri & Shortcuts PushWard ships App Intents, so you can drive it from Siri, the Shortcuts app, and personal automations right on your device β€” no integration key or API call required. _Requires PushWard iOS app 1.5.0 or later._ Both actions run as the signed-in user on that device, so anything they do stays on your own account. They require iOS 26 or later with PushWard installed and signed in. ## Send a notification with PushWard Sends a push notification to yourself -- it lands on every device signed in to your PushWard account, exactly like a notification from the API. It takes two fields, a Title and a Message, so in the Shortcuts editor the action reads "Send \[Title\] with \[Message\]." - **From Siri** -- say "Send a notification with PushWard" and give it a Title and a Message when prompted. - **In a Shortcut** -- add the action, then wire its Message (and Title) to an earlier step (a calculation, a web request, the current weather) so the notification carries a real result. - **From an automation** -- trigger it on arriving home, a time of day, or an NFC tag so you get a nudge without lifting a finger. πŸ’‘ Tip Because the action runs as you, it needs no `hlk_` key and counts against nothing -- it is the on-device equivalent of a notification you would otherwise send yourself over the API. ## Show my unread PushWard notifications Opens PushWard straight to your inbox, filtered to unread notifications. Handy as a one-tap way to catch up on everything that arrived while you were away. - **From Siri** -- say "Show my unread PushWard notifications." - **As a shortcut** -- add it to your Home Screen, the Action button, or Back Tap for instant access to the inbox. ## Building automations Both actions appear under **PushWard** when you add a step in the Shortcuts app, and inside **Automation** when you build a personal automation. For notifications driven by an external service or a server rather than your own device, use the [notifications API](https://pushward.app/docs/notifications) or one of the [integrations](https://pushward.app/docs/integrations) instead. --- # Widgets Push-driven iOS Home Screen, Lock Screen, and StandBy widgets. Declare a widget once, update its content with a PATCH, and the user's WidgetKit instances refresh automatically. β„Ή Info Widgets require **iOS 26 or later**. On older versions the iOS app hides the widget management screen and your widgets remain dormant β€” the server keeps the content current so they render correctly once the user upgrades. Widgets are persistent β€” once the user pins one to their Home Screen, Lock Screen, or StandBy, it stays there until they remove it. Use a widget for ambient state that should always be glanceable (a sensor reading, a server health indicator, a daily counter). For something that _is currently happening and has a known end_ β€” a download, a timer, a print β€” reach for a [Live Activity](https://pushward.app/docs/live-activities) instead. ## Templates Each template is tuned for a different shape of data. Tap a card for that template's field list, worked example, and behavior notes; the shared content fields they all accept live in the [API reference](https://pushward.app/docs/widgets/api). [### `value` β€” Value A single big number with optional unit, label, and trend arrow. Best for sensor readings and counters. **Required:** none (optional value, unit, label, trend) ![Value widget β€” large "20" deploys count on a dark card with badges and an Up trend arrow.](https://pushward.app/_app/immutable/assets/value-540.MvykcigX.png)](https://pushward.app/docs/widgets/templates/value) [### `progress` β€” Progress A progress bar with label and accent color. Best for completion percentages and rollouts. **Required:** value 0.0 – 1.0 ![Progress widget β€” Pages 100% with a green progress bar and "10 of 10 acked".](https://pushward.app/_app/immutable/assets/progress-540.S20jmmVH.png)](https://pushward.app/docs/widgets/templates/progress) [### `status` β€” Status An iconic status pill driven by severity (info, warning, critical, success). No numbers β€” just state. **Required:** none (optional severity: info, warning, critical, success) ![Status widget β€” Incident card with a green warning octagon and "resolved" pills.](https://pushward.app/_app/immutable/assets/status-540.C0lXmcYT.png)](https://pushward.app/docs/widgets/templates/status) [### `gauge` β€” Gauge A needle gauge between a configurable min and max. Best for SLOs, error budgets, and capacity. **Required:** value, min\_value, max\_value ![Gauge widget β€” Errors dial at 0.4% out of 5%, with MIN/MAX labels and Mute/Logs buttons.](https://pushward.app/_app/immutable/assets/gauge-540.CNzGtKQB.png)](https://pushward.app/docs/widgets/templates/gauge) [### `stat_list` β€” Stat list Up to six label/value rows. Best for compact dashboards β€” MRR / Subs / Trials, Up / Down / Maintenance. **Required:** stat\_rows (array, 1 – 6 entries) ![Stat list widget β€” On-call card with Primary, Secondary, and Time left rows.](https://pushward.app/_app/immutable/assets/stats-540.C5CvAym5.png)](https://pushward.app/docs/widgets/templates/stat-list) _Requires PushWard iOS app 1.6 or later._ [### `trend` β€” Trend A current reading with its recent history drawn as a sparkline. Best for latency, throughput, price, or any number worth seeing in context. **Required:** value, points (2 – 48 numbers, oldest first) ![Trend widget β€” p95 latency at 371 ms above a rising cyan sparkline, with an Up arrow and Refresh and History buttons.](https://pushward.app/_app/immutable/assets/trend-540.DKRuG3hF.png)](https://pushward.app/docs/widgets/templates/trend) [### `countdown` β€” Countdown A countdown to a date that keeps ticking on device between pushes. Add a start date and it fills a progress bar on its own. **Required:** end\_date (add start\_date for a progress bar) ![Countdown widget β€” Cert renewal reading 2:05:08 over a half-filled indigo bar, tagged api.pushward.app.](https://pushward.app/_app/immutable/assets/countdown-540.D8pQkKxU.png)](https://pushward.app/docs/widgets/templates/countdown) [### `battery` β€” Battery Charge levels for up to eight devices, drawn as rings. Best for smart-home batteries, sensors, and anything else you would rather not find flat. **Required:** devices (1 – 8 entries of name + level) ![Battery widget β€” four green charge rings reading 68%, 45% with a charging bolt, 18% in red, and 92%.](https://pushward.app/_app/immutable/assets/battery-540.Djvbu_Fh.png)](https://pushward.app/docs/widgets/templates/battery) [### `schedule` β€” Schedule A timeline of upcoming periods β€” tariff prices, delivery windows, shifts β€” with the period happening now highlighted. **Required:** periods (1 – 48 entries of start + value, strictly increasing) ![Schedule widget β€” Tariff reading 18.7 cents for the 01:11–02:11 period, over 12 hourly bars banded green through red by price with the current period highlighted.](https://pushward.app/_app/immutable/assets/schedule-540.D3MgPR4Z.png)](https://pushward.app/docs/widgets/templates/schedule) [### `flow` β€” Flow Energy, water, or data moving between sources, storage, and consumers, with a live rate on every leg. **Required:** flow with at least one of inputs, output, storage, exchange ![Flow widget β€” Power showing Solar at 3,200 W, Grid at 1,400 W with an outbound arrow, Battery at 800 W with an inbound charging arrow, and Home drawing 1,000 W.](https://pushward.app/_app/immutable/assets/flow-540.m-lAY5Th.png)](https://pushward.app/docs/widgets/templates/flow) Preview any of them on the [playground](https://pushward.app/playground). ## How it works 1. Your integration calls `POST /widgets` to declare a widget by `slug`, with a `template` and initial `content`. 2. The user opens the widget picker on their device and selects your slug for any system widget family they want β€” small, medium, large, accessory-rectangular, accessory-circular, or accessory-inline. The same slug can appear in multiple slots. 3. Your integration calls `PATCH /widgets/{slug}` whenever the underlying value changes. The server merges (RFC 7396) and pushes a thin update; the iOS widget extension reloads its timeline. 4. If a push is missed (background fetch coalescing, no network), iOS calls back into the extension every ~30 minutes for a freshness pull β€” `GET /widgets/{slug}` always returns the latest content. ## Two ways to create widgets Widgets don't require an integration to exist. There are two paths to the same backend, and both can target the same slug β€” a widget created in the app can later be driven by an integration, and vice versa. - **In the iOS app.** Open the **Widgets** tab in PushWard, choose a template, and fill in the slug, name, and initial content. This is the right path for personal widgets β€” a temperature you key in by hand, a morale dial, a counter you bump from a Shortcut. The new slug appears immediately in the iOS widget picker for Home Screen, Lock Screen, and StandBy. - **Via the HTTP API.** Use this when an external service should keep the widget content current β€” a homelab probe, a CI job, a smart-home automation. See the [API reference](https://pushward.app/docs/widgets/api). ## Authentication The widgets API is reachable with an [integration key](https://pushward.app/docs/api/authentication) (`hlk_`) that has the `widgets` flag enabled. --- # 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](https://pushward.app/docs/api/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 | 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](#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. | 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`](https://pushward.app/docs/api/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](https://pushward.app/docs/api/activities#merge-patch). 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](https://pushward.app/docs/widgets); 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](https://pushward.app/docs/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](https://pushward.app/docs/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](https://pushward.app/docs/widgets/templates/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](#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. | | `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](https://pushward.app/docs/api/activities#tap-actions) β€” and the same iOS dispatcher β€” as Live Activity tap actions and [notification actions](https://pushward.app/docs/notifications/actions), including the best-effort caveat on silent webhooks. The three slots β€” `tap_action`, `url_action`, `secondary_url_action` β€” are described in the [Content](#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" } } }' ``` _Requires PushWard iOS app 1.6 or later._ ## 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. | Mark a widget stale after 15 minutes without an update ``` 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 } }' ``` β„Ή Info 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](https://pushward.app/docs/api/activities#errors) 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. | --- # Value widget One big number with an optional unit, label, and trend arrow. Best for sensor readings, counters, and anything that fits on a single line. Nothing is required beyond `template`, which makes it the right first widget to wire up β€” post a number and it renders. ## Fields | Field | Type | Notes | | --- | --- | --- | | `template` | string | **Required.** Must be `"value"` | | `value` | float | The number rendered large. Must be finite. Absent puts the `label` in the hero slot instead, which is how you say "no reading yet" without faking a zero | | `unit` | string | Rendered after the value in a smaller weight (e.g. `"Β°C"`, `"req/s"`). Max 32 characters | | `min_value`, `max_value` | float | Context bounds, drawn as a Range line on the large family when no `stat_rows` are set. `min_value` must be strictly less than `max_value` when both are sent | | `trend` | string | `up` / `down` / `flat`. Renders an inline arrow next to the value on the medium family. It is your annotation β€” the server never derives it | | `stat_rows` | object\[\] | Supporting rows below the number on the large family β€” the first 3 are drawn, and they take the place of the trend badge and Range line. Each is `{ label (≀32), value (≀32), unit? (≀16) }`; the wire cap is 6 | | `label`, `subtitle`, `icon`, `accent_color`, `background_color`, `text_color` | string | Shared content fields β€” see the [API reference](https://pushward.app/docs/widgets/api#content) | ## Example: deploys today PATCH /widgets/deploys ``` { "content": { "template": "value", "value": 18, "label": "Deploys", "subtitle": "Today Β· 14 prod Β· 4 staging", "icon": "shippingbox.fill", "accent_color": "cyan", "trend": "up" } } ``` ## Behavior The number is formatted on device for the user's locale, so send a raw float rather than a pre-formatted string: send `1234.5`, not `"1,234.5"`. Precision narrows as the value grows β€” two decimals below 10, one decimal to 100, none to 10,000, then `12.3K` and `1.2M` above that β€” which keeps the figure legible at the hero size on every family. If you need exact characters, a currency symbol, or a fixed number of decimals, use [stat\_list](https://pushward.app/docs/widgets/templates/stat-list) instead: every value there is a string you control. β„Ή Info `min_value` and `max_value` do not clamp anything on this template β€” they only give the widget context to draw against. A value outside them is rendered as sent. If you want the reading positioned on a dial between two bounds, that is the [gauge](https://pushward.app/docs/widgets/templates/gauge) template. --- # Progress widget How far along something is, drawn as a bar. Best for rollouts, backups, quotas, and any task with a known finish line. ## Fields | Field | Type | Notes | | --- | --- | --- | | `template` | string | **Required.** Must be `"progress"` | | `value` | float | **Required** (see the self-advancing note below for the one exception). A fraction in `0.0 – 1.0`; `0.42` renders as 42%. Out-of-range values are rejected with `422` | | `trend` | string | `up` / `down` / `flat`. Drawn as a badge under the bar on the large family, when no `stat_rows` are set | | `stat_rows` | object\[\] | Supporting rows under the bar on the large family β€” the first 3 are drawn, in place of the trend badge. Each is `{ label (≀32), value (≀32), unit? (≀16) }`; the wire cap is 6 | | `label`, `subtitle`, `icon`, `accent_color`, `background_color`, `text_color` | string | Shared content fields β€” see the [API reference](https://pushward.app/docs/widgets/api#content) | ## Example: incident acknowledgements PATCH /widgets/oncall-pages ``` { "content": { "template": "progress", "value": 0.6, "label": "Pages", "subtitle": "6 of 10 acked Β· INC-2841", "icon": "checklist", "accent_color": "green", "stat_rows": [ { "label": "Acked", "value": "6" }, { "label": "Total", "value": "10" }, { "label": "Severity", "value": "SEV-2" } ] } } ``` ## Behavior The percentage shown above the bar is derived from `value`, so the number and the fill can never disagree. There is no "indeterminate" state: a job whose progress you cannot measure is better modelled as a [status](https://pushward.app/docs/widgets/templates/status) widget than as a bar stuck at some arbitrary fraction. Bars do not animate between pushes β€” each update paints the new fill immediately. For something that should keep moving on its own, use the date pair below, or reach for a [Live Activity](https://pushward.app/docs/live-activities). _Requires PushWard iOS app 1.6 or later._ ## Self-advancing progress Sending `start_date` and `end_date` together makes the bar advance on device across that window with no further pushes β€” useful for anything whose progress is purely a function of the clock: a maintenance window, a lease, a rendering job with a known finish time. | Field | Type | Notes | | --- | --- | --- | | `start_date` | string | RFC 3339. Must be after 2000-01-01, no more than 366 days ahead, and strictly before `end_date` | | `end_date` | string | RFC 3339, same bounds. With both dates set, `value` becomes optional | A two-hour maintenance window ``` { "content": { "template": "progress", "label": "Maintenance", "subtitle": "db-primary Β· read-only", "icon": "wrench.and.screwdriver.fill", "accent_color": "orange", "start_date": "2027-05-10T22:00:00Z", "end_date": "2027-05-11T00:00:00Z", "value": 0.0 } } ``` β„Ή Info Send `value` as well, as the example does: builds released before 1.6 ignore the dates and render it. Builds that understand the pair prefer the dates β€” they advance the bar from the window, and a window whose `end_date` has passed reads 100% no matter what `value` says. --- # Status widget A single state β€” healthy, degraded, down β€” carried by an icon and a severity color instead of a number. ## Fields | Field | Type | Notes | | --- | --- | --- | | `template` | string | **Required.** Must be `"status"` | | `severity` | string | One of `info`, `warning`, `critical`, `success`. Not required by the server, but the field the template is built around: it picks both the icon and the color | | `label` | string | The headline under the icon. Falls back to the widget's `name` when unset. Max 256 characters | | `subtitle` | string | Split at `Β·`, `|`, `β€’`, `,` and `;` into tag pills tinted by severity. Max 256 characters | | `icon` | string | Overrides the severity icon. SF Symbol name, or an MDI icon prefixed `mdi:`. Max 128 characters | | `accent_color`, `background_color`, `text_color` | string | Shared content fields β€” see the [API reference](https://pushward.app/docs/widgets/api#content). An explicit `accent_color` overrides the severity color | ## Example: incident state PATCH /widgets/incident ``` { "content": { "template": "status", "severity": "warning", "label": "Incident", "subtitle": "INC-2841 Β· api-gateway Β· investigating", "url_action": { "title": "Ack", "icon": "checkmark.circle.fill", "url": "https://example.com/ack/2841" } } } ``` ## Behavior Severity picks the icon as well as the color, and that pairing is deliberate: on the Lock Screen widgets render in a single vibrant tint, so a red circle and a green circle look identical there. The _shape_ is what survives β€” an octagon for `critical`, a triangle for `warning`, a check for `success`, an "i" for `info`, and a plain filled circle when severity is unset. β„Ή Info Setting `icon` replaces the severity glyph everywhere, including the Lock Screen, so a custom icon costs you that at-a-glance distinction. Use it for a brand or service mark on a widget that stays at one severity, not on one that flips between states. Severity also tints the whole surface β€” header hairline, pills, and the action buttons β€” rather than just the icon, so a critical widget does not end up red with a blue button. If you find yourself encoding a count into the subtitle ("3 down"), the shape you want is probably [stat\_list](https://pushward.app/docs/widgets/templates/stat-list) or [value](https://pushward.app/docs/widgets/templates/value). Status is for the state itself. --- # Gauge widget A value on a dial between a fixed minimum and maximum. Best for SLOs, error budgets, temperatures, and capacity. The `gauge` template answers "how close to the limit is this?", which a bare number cannot: 2.4 means nothing until you know the ceiling is 5. ## Fields | Field | Type | Notes | | --- | --- | --- | | `template` | string | **Required.** Must be `"gauge"` | | `value` | float | **Required.** Must be finite and fall within `min_value` – `max_value`; outside that range the request is rejected with `422` | | `min_value` | float | **Required.** Start of the arc. Must be strictly less than `max_value` | | `max_value` | float | **Required.** End of the arc | | `unit` | string | Appended to the value and to the MIN / MAX end labels. Max 32 characters | | `trend` | string | `up` / `down` / `flat`. Accepted by the server; the current iOS gauge does not draw an arrow, so treat it as forward-looking metadata rather than something users will see | | `label`, `subtitle`, `icon`, `accent_color`, `background_color`, `text_color` | string | Shared content fields β€” see the [API reference](https://pushward.app/docs/widgets/api#content) | ## Example: error rate against an SLO PATCH /widgets/error-rate ``` { "content": { "template": "gauge", "value": 2.4, "min_value": 0, "max_value": 5, "unit": "%", "label": "Errors", "subtitle": "api-gateway Β· last 5m Β· SLO 1.0%", "icon": "chart.line.uptrend.xyaxis", "accent_color": "red" } } ``` ## Behavior Pick bounds once and keep them fixed. The arc's meaning comes entirely from where the needle sits between them, so a `max_value` that tracks the current peak makes every update look the same and hides exactly the trend you built the widget to see. Choose the number that means "this is the limit" β€” the SLO, the disk size, the tank capacity β€” and leave it alone. Because the server rejects a value outside the bounds, a metric that can legitimately overshoot needs headroom in `max_value` rather than a clamp at the sender. Set the ceiling above the worst case you expect, or keep sending the raw number to a [value](https://pushward.app/docs/widgets/templates/value) widget and let the subtitle carry the threshold. β„Ή Info The MIN and MAX end labels take the same `unit` as the value, so a unit that reads well next to a big number ("%", "Β°C") also has to read well in `MAX 5%`. Long words ("requests per second") are better placed in `label` or `subtitle`. For a fraction that is already a percentage of a known whole, [progress](https://pushward.app/docs/widgets/templates/progress) says the same thing with less configuration. _Requires PushWard iOS app 1.6 or later._ When the recent history matters more than the ceiling, [trend](https://pushward.app/docs/widgets/templates/trend) plots the same reading with the values that led to it. --- # Stat list widget Up to six label and value rows in one card. Best for compact dashboards β€” MRR, subscribers, trials β€” where a chart would say less. ## Fields | Field | Type | Notes | | --- | --- | --- | | `template` | string | **Required.** Must be `"stat_list"` | | `stat_rows` | object\[\] | **Required.** Between 1 and 6 rows, drawn in the order you send them. Replaced wholesale on PATCH β€” re-send the full list ([why](https://pushward.app/docs/api/activities#merge-patch)) | | `stat_rows[].label` | string | **Required.** Non-blank, max 32 characters. The left-hand caption | | `stat_rows[].value` | string | **Required.** Non-blank, max 32 characters. A _string_, not a number β€” rendered exactly as sent | | `stat_rows[].unit` | string | Rendered after the value. Max 16 characters | | `label`, `subtitle`, `icon`, `accent_color`, `background_color`, `text_color` | string | Shared content fields β€” see the [API reference](https://pushward.app/docs/widgets/api#content) | ## Example: SaaS dashboard PATCH /widgets/saas-dashboard ``` { "content": { "template": "stat_list", "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": "%" } ] } } ``` ## Behavior Values are strings, so formatting is yours to decide and yours to keep consistent: the currency symbol, the thousands separator, the number of decimals. Nothing is rounded or abbreviated on device, which is the whole reason to choose this template over [value](https://pushward.app/docs/widgets/templates/value) β€” but it also means a long value is a long value, and 32 characters of it will not fit on a small widget. How many rows actually render depends on the placement. Medium and large Home Screen widgets show all six by default; the small widget shows four, and the Lock Screen rectangular shows three, packing rows two-up to reach six when every value is very short. Users can change this per widget in the PushWard app: _Compact_ packs two columns to surface up to six rows on any size, at the cost of truncated labels on the smaller placements, and _Comfortable_ keeps one column with larger rows and shows fewer of them. Put the row that matters most first β€” it is the one that survives every placement. _Requires PushWard iOS app 1.6 or later._ ## Row timers A row can carry a `timer` instead of a static trailing value, which turns that row into a live readout β€” time until a maintenance window, time since the last successful backup β€” that keeps moving between pushes. | Field | Type | Notes | | --- | --- | --- | | `stat_rows[].timer.date` | string | **Required** when `timer` is present. RFC 3339, after 2000-01-01 and no more than 366 days ahead. A past date counts up, a future date counts down | | `stat_rows[].timer.style` | string | `timer` (the default) ticks like `01:23:45`; `relative` renders a coarse unit like `2 min` | A backup row that counts up on its own ``` { "content": { "template": "stat_list", "label": "Backups", "stat_rows": [ { "label": "Last run", "value": "12 min ago", "timer": { "date": "2027-05-10T08:00:00Z", "style": "relative" } }, { "label": "Repos", "value": "14" }, { "label": "Failed", "value": "0" } ] } } ``` β„Ή Info `value` stays required on a row that carries a timer, and it stays the static fallback: builds released before 1.6 render it and ignore the timer. Send the text the value had at push time ("12 min ago"), not a placeholder β€” an older phone shows exactly that string until the next update. --- # Trend widget A current reading with its recent history drawn as a sparkline. Best for latency, throughput, price, or any number worth seeing in context. _Requires PushWard iOS app 1.6 or later._ ## Fields | Field | Type | Notes | | --- | --- | --- | | `template` | string | **Required.** Must be `"trend"` | | `value` | float | **Required.** The current reading, rendered large above the chart. Must be finite | | `points` | number\[\] | **Required.** The sparkline history, **oldest first**. Between 2 and 48 entries, each finite. Replaced wholesale on PATCH β€” re-send the whole window, not one new point ([why](https://pushward.app/docs/api/activities#merge-patch)) | | `unit` | string | Unit rendered after the value (e.g. `"ms"`, `"%"`, `"req/s"`). Max 32 characters | | `min_value` | float | Fixed lower bound of the chart. Must be less than `max_value` when both are sent | | `max_value` | float | Fixed upper bound of the chart. With either bound missing the widget auto-scales to the posted points | | `trend` | string | `up` / `down` / `flat`. Renders an inline arrow next to the value; it is your annotation, not derived from `points` | | `label`, `subtitle`, `icon`, `accent_color`, `background_color`, `text_color` | string | Shared content fields β€” see the [API reference](https://pushward.app/docs/widgets/api#content) | ## Example: p95 latency PATCH /widgets/api-latency ``` { "content": { "template": "trend", "value": 371, "unit": "ms", "points": [312, 305, 298, 310, 322, 331, 340, 336, 329, 348, 355, 362, 371], "label": "p95 latency", "subtitle": "api-gateway Β· last hour", "icon": "waveform.path.ecg", "accent_color": "teal", "trend": "up" } } ``` ## Behavior Points are plotted left to right in the order you send them, so the last entry sits under the current reading β€” keep `value` and the final point in agreement or the chart appears to lag the number above it. Without `min_value` and `max_value` the chart scales to whatever range the posted points span, which makes a flat series look dramatic; set both bounds when you want consecutive updates to stay visually comparable (an SLO ceiling, a 0 – 100 percentage). --- # Countdown widget A countdown to a date that keeps ticking on device between pushes. Add a start date and it fills a progress bar on its own. _Requires PushWard iOS app 1.6 or later._ WidgetKit redraws the number itself, so one push sets the target and the widget stays correct for as long as it is pinned β€” you only send another update when the target moves. ## Fields | Field | Type | Notes | | --- | --- | --- | | `template` | string | **Required.** Must be `"countdown"` | | `end_date` | string | **Required.** RFC 3339 timestamp of the target. Must be after 2000-01-01 and no more than 366 days in the future. A date already in the past counts _up_ instead of down | | `start_date` | string | RFC 3339 timestamp the countdown started from. Must be strictly before `end_date`. Adds a progress bar that advances on device | | `expired_text` | string | Shown in place of the countdown once `end_date` passes. Max 64 characters. Absent means the widget keeps counting, showing time elapsed since the target | | `label`, `subtitle`, `icon`, `accent_color`, `background_color`, `text_color` | string | Shared content fields β€” see the [API reference](https://pushward.app/docs/widgets/api#content) | ## Example: certificate renewal PATCH /widgets/cert-renewal ``` { "content": { "template": "countdown", "end_date": "2027-05-10T16:13:00Z", "start_date": "2027-05-10T13:00:00Z", "expired_text": "Renewing", "label": "Cert renewal", "subtitle": "api.pushward.app", "icon": "lock.shield.fill", "accent_color": "indigo" } } ``` β„Ή Info Dates are bounded on both sides: anything before 2000-01-01 is rejected outright (it catches milliseconds accidentally sent as seconds, which land in 1970), and anything more than 366 days ahead is rejected too. The bounds are re-checked on every PATCH against the merged content, so a widget whose `end_date` has aged into the past stays patchable. --- # Battery widget Charge levels for up to eight devices, drawn as rings. Best for smart-home batteries, sensors, and anything else you would rather not find flat. _Requires PushWard iOS app 1.6 or later._ The `battery` template renders one ring per device, in the same idiom as Apple's own Batteries widget. ## Fields | Field | Type | Notes | | --- | --- | --- | | `template` | string | **Required.** Must be `"battery"` | | `devices` | object\[\] | **Required.** Between 1 and 8 device objects, rendered in the order you send them. Replaced wholesale on PATCH β€” re-send the full roster, unchanged devices included ([why](https://pushward.app/docs/api/activities#merge-patch)) | | `devices[].name` | string | **Required.** Must not be empty. Max 32 characters | | `devices[].level` | float | **Required.** Charge percentage, 0 to 100 | | `devices[].charging` | boolean | Overlays a charging bolt on the ring | | `devices[].icon` | string | SF Symbol shown inside the ring, or an MDI icon prefixed with `mdi:`. Max 128 characters | | `devices[].color` | string | Ring color β€” named color or hex (see [Colors](https://pushward.app/docs/colors)). Default is green, switching to red at or below 20% | | `device_sort` | object\[\] | Up to 2 sort keys applied to `devices` before they are stored, so the smaller families show the devices you care about. Omit to keep the order you send | | `device_sort[].field` | string | **Required.** `level` or `name`. Names compare case-insensitively | | `device_sort[].direction` | string | `asc` (default) or `desc` | | `label`, `subtitle`, `icon`, `accent_color`, `background_color`, `text_color` | string | Shared content fields β€” see the [API reference](https://pushward.app/docs/widgets/api#content) | ## Example: household devices PATCH /widgets/devices ``` { "content": { "template": "battery", "label": "Devices", "icon": "battery.75percent", "accent_color": "green", "devices": [ { "name": "Vacuum", "level": 68, "icon": "fan.fill" }, { "name": "Front lock", "level": 45, "icon": "lock.fill", "charging": true }, { "name": "Doorbell", "level": 18, "icon": "video.fill" }, { "name": "Sensor hub", "level": 92, "icon": "sensor.fill" } ] } } ``` ## Behavior Large Home Screen widgets list all eight devices; smaller families render as many as fit, taking them from the front of the array, so put the devices you care most about first. Once you track more than two devices that ordering matters, because the small family shows only the first two and the medium family the first four. Send `"device_sort": [{ "field": "level" }]` and the server reorders `devices` for you on every update, so the emptiest devices are the ones that stay visible as levels drift. Keys apply in order and the first one that separates two devices wins, so a second key is a tie-break. With one key, devices sharing a level keep the order you sent them in; add `{ "field": "name" }` after the level key and those ties resolve alphabetically instead. The same field may not appear in both keys. Lowest battery first, then alphabetical ``` { "content": { "template": "battery", "device_sort": [ { "field": "level", "direction": "asc" }, { "field": "name", "direction": "asc" } ], "devices": [ { "name": "Vacuum", "level": 68 }, { "name": "Front lock", "level": 45 }, { "name": "Doorbell", "level": 45 }, { "name": "Sensor hub", "level": 92 } ] } } ``` The server stores the sorted array, so every response and later `GET` return it reordered β€” and because the reordering happens on write rather than on the device, it works on every version of the app already installed. --- # Schedule widget A timeline of upcoming periods β€” tariff prices, delivery windows, shifts β€” with the period happening now highlighted. _Requires PushWard iOS app 1.6 or later._ ## Fields | Field | Type | Notes | | --- | --- | --- | | `template` | string | **Required.** Must be `"schedule"` | | `periods` | object\[\] | **Required.** Between 1 and 48 periods (two days of hourly data), in strictly increasing `start` order. Replaced wholesale on PATCH β€” re-send the whole window you want drawn (typically today plus tomorrow), never append period by period ([why](https://pushward.app/docs/api/activities#merge-patch)) | | `periods[].start` | string | **Required.** RFC 3339 timestamp the period begins. Must be strictly after the previous period's start, after 2000-01-01, and no more than 366 days ahead | | `periods[].value` | float | **Required.** The value for that period β€” price, load, headcount. Must be finite. Unit-agnostic; label it with the content-level `unit` | | `periods[].level` | string | `low` / `medium` / `high`. Sets the band color for that bar. Absent means the client derives bands from the posted range | | `unit` | string | Unit or currency symbol rendered with the values (e.g. `"Β’"`, `"kW"`). Max 32 characters | | `label`, `subtitle`, `icon`, `accent_color`, `background_color`, `text_color` | string | Shared content fields β€” see the [API reference](https://pushward.app/docs/widgets/api#content) | ## Example: hourly tariff PATCH /widgets/tariff ``` { "content": { "template": "schedule", "label": "Tariff", "subtitle": "Spot price Β· today", "icon": "bolt.badge.clock.fill", "unit": "Β’", "accent_color": "yellow", "periods": [ { "start": "2027-05-10T08:00:00Z", "value": 14.2 }, { "start": "2027-05-10T09:00:00Z", "value": 12.8 }, { "start": "2027-05-10T10:00:00Z", "value": 11.9, "level": "low" }, { "start": "2027-05-10T11:00:00Z", "value": 13.4 }, { "start": "2027-05-10T12:00:00Z", "value": 18.7 }, { "start": "2027-05-10T13:00:00Z", "value": 22.4, "level": "high" }, { "start": "2027-05-10T14:00:00Z", "value": 24.1, "level": "high" }, { "start": "2027-05-10T15:00:00Z", "value": 19.8 }, { "start": "2027-05-10T16:00:00Z", "value": 15.2 }, { "start": "2027-05-10T17:00:00Z", "value": 12.1, "level": "low" } ] } } ``` ## Behavior A period runs until the next period's `start`; the last one has no successor, so it extends to the edge of the chart. The widget finds the period containing the current time and highlights it, re-evaluating on device as the clock moves, so an hourly tariff needs one push per publish rather than one per hour. `level` is optional per period. When you omit it everywhere, the widget splits the posted range into terciles and colors each bar by which third its value falls in. Set it explicitly when the bands mean something specific (a supplier's own off-peak / peak definition, a staffing threshold). Mixing the two within one payload is allowed but reads inconsistently, so prefer all or nothing. --- # Flow widget Energy, water, or data moving between sources, storage, and consumers, with a live rate on every leg. _Requires PushWard iOS app 1.6 or later._ The `flow` template describes something moving through a system, in four generic slots: what comes in (`inputs`), what buffers it (`storage`), what is traded with the outside (`exchange`), and what consumes it (`output`). ## Fields | Field | Type | Notes | | --- | --- | --- | | `template` | string | **Required.** Must be `"flow"` | | `flow` | object | **Required.** At least one of `inputs`, `output`, `storage`, `exchange` must be present | | `flow.inputs` | object\[\] | Production sources (solar, wind, well). Up to 3 nodes. Replaced wholesale on PATCH β€” re-send every input each time you send any ([why](https://pushward.app/docs/api/activities#merge-patch)) | | `flow.output` | object | The consumption endpoint β€” the house, the cluster, the tap | | `flow.storage` | object | The buffer. The only slot that renders `level` | | `flow.exchange` | object | Two-way link with the outside β€” grid, mains, upstream provider | | `rate` | float | **Required on every node.** Instantaneous rate, finite. **Signed** on the two-way slots: see Behavior below | | `name` | string | Label override for that node (rename an input to "Wind"). Max 32 characters | | `total` | float | Cumulative total so far today (kWh, litres). Must not be negative | | `level` | float | Fill percentage, 0 to 100. Read on the `storage` slot only | | `icon`, `color` | string | SF Symbol (max 128 characters) and a named color or hex for that node | | `unit` | string | Content-level unit for every rate on the widget (e.g. `"W"`, `"L/min"`). Max 32 characters | ## Example: home energy PATCH /widgets/power ``` { "content": { "template": "flow", "label": "Power", "subtitle": "Solar Β· grid Β· home", "icon": "point.3.filled.connected.trianglepath.dotted", "unit": "W", "accent_color": "mint", "flow": { "inputs": [ { "name": "Solar", "rate": 3200, "total": 12.4, "icon": "sun.max.fill", "color": "yellow" } ], "output": { "name": "Home", "rate": 1000, "total": 9.8, "icon": "house.fill" }, "storage": { "name": "Battery", "rate": 800, "level": 76, "icon": "battery.75percent", "color": "green" }, "exchange": { "name": "Grid", "rate": -1400, "total": 3.1, "icon": "bolt.fill", "color": "cyan" } } } } ``` ## Behavior The sign of `rate` is what makes the two-way slots readable, and it means different things per slot. On `exchange`, positive is **inbound** (importing from the grid) and negative is **outbound** (exporting to it) β€” the example above is exporting 1400 W. On `storage`, positive is **filling** and negative is **draining**. Inputs and the output are one-directional, so their rates are plain magnitudes. Only the slots you send are drawn, so the same template covers a solar array with no battery, a water main with no local source, or a bare consumption meter. `total` renders under the rate where the widget family has room. Patching a single nested field like `flow.exchange.rate` leaves everything else untouched; clear a slot with an explicit `null`. --- # Email Send transactional email to addresses your account has verified, using the same integration key that drives your push notifications. Recipients confirm with a double opt-in link before they can receive mail. PushWard's email channel is a **verified-recipient transactional email relay**: you can only send mail to addresses your account has explicitly confirmed through a doubleΒ opt-in link. Messages are handed to a third-party email delivery provider; bounces and spam complaints are processed automatically. ## How it works 1. **Register a recipient.** The account owner adds an address to the verified list. PushWard sends that address a one-time confirmation link (valid for 48Β hours). 2. **The recipient confirms.** Clicking the link verifies the address. Only confirmed, non-unsubscribed addresses become sendable β€” and only for the account that verified them (verification is per-account, never shared). 3. **Your integrations send.** Call `POST /emails` to deliver transactional mail to a verified recipient, using the same integration key that powers your push notifications. Recipients can one-click unsubscribe at any time, and hard bounces or spam complaints suppress an address automatically. ## Authentication Sending email needs the dedicated **emails** capability. Your auto-created **default integration key** already has it enabled. To use a scoped key instead, enable its `emails` flag per-key in the iOS app's integration-keys screen. The flag is independent of `notifications`, so a key can send email without being able to send push (and vice versa). See [Authentication](https://pushward.app/docs/api/authentication). β„Ή Info Email is metered: **50** messages/month on the free tier and **200** messages/month on paid. The quota resets on the 1st (UTC). See [Limits](https://pushward.app/docs/limits) for how email compares to the other quotas. ## Send an Email POST `/emails` Send a transactional email to a verified, non-unsubscribed recipient of the calling account. ### Request Body | Field | Type | Required | Description | | --- | --- | --- | --- | | `to` | string | Yes | Recipient address (3–254 chars). Must be a **verified**, non-unsubscribed recipient of your account, or the request is rejected with `403`. Bare addresses only β€” `Name ` display forms are rejected. | | `subject` | string | Yes | Subject line (1–256 chars). | | `html_body` | string | No\* | HTML body (up to 256Β KB). | | `text_body` | string | No\* | Plain-text body (up to 64Β KB). | \* Provide `html_body`, `text_body`, or both β€” at least one is required. Send a deploy summary ``` curl -X POST https://api.pushward.app/emails \ -H "Authorization: Bearer hlk_YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "to": "alerts@example.com", "subject": "Nightly backup complete", "text_body": "All 4 repositories backed up in 12m 30s.", "html_body": "

All 4 repositories backed up in 12m 30s.

" }' ``` **Response (201):** ``` { "id": 42, "to": "alerts@example.com", "subject": "Nightly backup complete", "status": "sent", "provider_message_id": "0100018f3a2b...", "created_at": "2026-06-09T02:00:00Z", "delivery": "all" } ``` The send response carries two read-only fields describing the send-time outcome (present only on the send response, not on the stored history rows shown in the app): | Field | Values | Meaning | | --- | --- | --- | | `delivery` | `all`, `none` | Whether the message was handed off for delivery. | | `reason` | `suppressed`, `send_failed` | Why `delivery` is `none`. `suppressed` = the address previously hard-bounced or was marked as spam (recorded in history, no message sent); `send_failed` = the delivery provider rejected the hand-off. Empty on success. | β„Ή Info A send to a globally-suppressed address returns `201` with `delivery: "none"` and `reason: "suppressed"` rather than an error: the attempt is logged for your history, but no mail goes out and no quota is consumed. ## Recipients & History Recipient management β€” registering an address, sending its double-opt-in confirmation, listing recipients, resending a confirmation, and removing one β€” is handled **in the PushWard app**, not through the public API. You curate the verified list in the app; your integration keys can only send to addresses already confirmed there. Your **send history** β€” every email this account has sent or attempted β€” is likewise viewable in the app's Email tab, including each message's delivery `status`: `sent`, `bounced`, `complained`, `failed`, or `suppressed` (updated asynchronously as the delivery provider reports bounces and complaints). ## Verification & Deliverability - **Double opt-in.** Confirmation links are single-use and expire after **48Β hours**; the app can resend a fresh one to a still-pending address. - **One-click unsubscribe.** Every email automatically carries the [RFCΒ 8058](https://www.rfc-editor.org/rfc/rfc8058) one-click unsubscribe headers and a link β€” you don't add anything. An unsubscribe is per-account: it stops mail from your account to that address, and you can re-register the address (with a new confirmation) later. - **Automatic suppression.** A hard bounce or spam complaint suppresses the address globally and immediately β€” subsequent sends are silently skipped and logged as `suppressed`. - **Recipient cap.** Each account can keep up to **50** verified recipients. See [Limits](https://pushward.app/docs/limits). ## Errors Email errors use the same [RFC 9457 Problem Details](https://pushward.app/docs/api/activities#errors) body shape and stable `code` values as the rest of the API. | Status | Code | Meaning | | --- | --- | --- | | `400` | `email.invalid` | `to`/`address` is not a valid address, or no body was provided. | | `403` | `email.recipient_not_verified` | `to` is not a verified, non-unsubscribed recipient of your account. | | `429` | `quota.exceeded` | Monthly email quota reached (or IP rate limit). Pairs with `Retry-After` and a `reset_at` timestamp. | | `503` | `email.not_enabled` | Email sending is not enabled on this server instance. | --- # Sharing Share a Live Activity with other PushWard users through time-limited codes. Recipients see the same Dynamic Island and Lock Screen surfaces, updated in real time β€” and joining as a viewer is free. β„Ή Info **Recipients join free.** Redeeming a viewer share code does not require a subscription β€” only the person _creating_ shares needs one. Each owner can share with up to **5 free recipients**; recipients who have their own subscription don't count toward that limit. ## How sharing works 1. **Generate a share code.** In the PushWard app, open an activity and tap **Share**. Pick a role, an optional use limit, and an expiry, and the app generates a short alphanumeric code plus a share link. 2. **Send the code or link.** A share link looks like `https://pushward.app/share/CODE` and opens straight into the PushWard app on the recipient's iPhone. The code can also be typed in manually under **Settings > Sharing > Join with Code**. 3. **The recipient joins.** Redeeming the code adds the activity to the recipient's account. If the activity is currently ongoing, it appears as a Live Activity on their Lock Screen and Dynamic Island right away β€” every update, state change, and completion push is mirrored in real time. ## Roles | Role | Can do | | --- | --- | | **Viewer** (default) | See the activity and receive all its Live Activity pushes. Read-only. | | **Editor** | Everything a viewer can, plus send updates to the activity. | ## Who needs Premium | Action | Subscription needed? | | --- | --- | | Create a share code (any role) or grant access directly | Yes β€” the owner needs an active subscription | | Redeem a **viewer** share code | **No** β€” recipients join free | | Redeem an **editor** share code | Yes β€” the recipient needs their own subscription | | Redeem a **pattern** share code | Yes β€” the recipient needs their own subscription | | Be upgraded from viewer to editor by the owner | Yes β€” the recipient needs their own subscription | The free-recipient allowance is per owner: each owner can have up to **5** distinct free recipients across all of their shared activities at any one time. Recipients with an active subscription of their own never count against it, and removing a free recipient frees the slot immediately. Free accounts also have a cap, set well above typical household use, on how many shared activities they can hold at once from all owners combined. ## Share code options | Option | Values | Default | | --- | --- | --- | | Role | `viewer` or `editor` | `viewer` | | Max uses | How many times the code can be redeemed | 1 | | Expiry | 1 minute to 7 days | 24 hours | ## Pattern shares Instead of sharing one activity, a **pattern share** grants access to every activity whose slug matches a wildcard pattern β€” current _and_ future. For example, `deploy-*` shares every deployment activity you'll ever create, so a teammate joins once and automatically sees each new deploy. Pattern shares are created from **Settings > Sharing** in the app and use the same code/link mechanics; redeeming one requires the recipient to have a subscription. ## Revoking and leaving - **Owners** can delete a share code at any time, and can revoke any individual recipient's access from the activity's share sheet. Revoking removes the activity from the recipient's account. - **Recipients** can leave a shared activity at any time. A free recipient who no longer holds any of an owner's shares stops counting toward that owner's free-recipient limit. ## Delivery and quotas - Recipients receive **push-to-start** for ongoing shared activities β€” the Live Activity appears on their devices without them opening the app. - Receiving a shared activity consumes **none of the recipient's quota**. Free recipients keep their full monthly allowance for their own activities. - Updates are charged to the **owner**: one Live Activity update slot per update, regardless of how many recipients the activity has. --- # Integrations Connect PushWard to your existing tools and services. Each integration authenticates with your integration key. The fastest way to get started is the [**Relay**](https://pushward.app/docs/integrations/relay) β€” point webhooks from 19+ services at `relay.pushward.app` with no deployment needed. [ ![Relay logo](https://pushward.app/_app/immutable/assets/webhook.DfE0AVox.svg) ### Relay Recommended Point webhooks from 19+ services at relay.pushward.app β€” no deployment needed, just add your integration key. ](https://pushward.app/docs/integrations/relay)[ ![Home Assistant logo](https://pushward.app/_app/immutable/assets/home-assistant.DHxJrpSK.svg) ### Home Assistant Recommended Track entity state changes with a native HACS integration. No Docker container needed. ](https://pushward.app/docs/integrations/home-assistant)[ ![Grafana logo](https://pushward.app/_app/immutable/assets/grafana.BQ5xXuzx.svg) ### Grafana Turn Grafana alerts into Live Activities with timeline sparklines backfilled from Prometheus. ](https://pushward.app/docs/integrations/grafana)[ ![GitHub Actions logo](https://pushward.app/_app/immutable/assets/github-dark.B4V1--n4.svg) ### GitHub Actions Track CI/CD workflow progress with the steps template. Get real-time build status on your Lock Screen. ](https://pushward.app/docs/integrations/github-actions)[ ![ArgoCD logo](https://pushward.app/_app/immutable/assets/argo-cd.B6eQQN2E.svg) ### ArgoCD Track application syncs as a three-step pipeline β€” Syncing, Rolling out, Deployed β€” through the hosted relay. ](https://pushward.app/docs/integrations/argocd)[ ![SABnzbd logo](https://pushward.app/_app/immutable/assets/sabnzbd.HERcixTX.svg) ### SABnzbd Track download progress with the generic template. See file names, speed, and ETA on your Lock Screen. ](https://pushward.app/docs/integrations/sabnzbd)[ ![Bambu Lab logo](https://pushward.app/_app/immutable/assets/bambulab.CL3HrVuY.svg) ### Bambu Lab Track 3D print progress with layer counts, nozzle temperature, and ETA via local MQTT. ](https://pushward.app/docs/integrations/bambulab)[ ![Unraid logo](https://pushward.app/_app/immutable/assets/unraid.B_VLs3Lu.svg) ### Unraid Forward Unraid notifications and watch parity checks, backups, mover, and UPS events as Live Activities. ](https://pushward.app/docs/integrations/unraid) ## Getting Started with Integrations 1. Copy your default integration key from the iOS app under **Settings β†’ Integration Key** 2. Create the [activities](https://pushward.app/docs/api/activities) that the integration will track 3. Configure the external service to send `PATCH /activities/{slug}` requests using the integration key --- # Relay Point your services' webhooks at `relay.pushward.app` and get push notifications without Docker, self-hosting, or per-user configuration. ## Supported Providers ### Grafana POST `/grafana` notification #### Events - Firing - Resolved (grouped by alertname) ![Live Activity demonstration](https://pushward.app/_app/immutable/assets/alert-poster.67WWFVCt.webp) ## Public Relay PushWard hosts a public relay at `relay.pushward.app` β€” no deployment required. Just point your services at it with your integration key. It supports all 19 providers listed above; if you prefer to self-host, see the [self-hosted setup](#self-hosted) further down. Example: Grafana webhook ``` # Grafana Contact Point URL: https://relay.pushward.app/grafana # HTTP Header: Authorization: Bearer hlk_YOUR_INTEGRATION_KEY ``` ## How It Works 1. **Receive** β€” external services send webhooks to provider-specific routes 2. **Authenticate** β€” the integration key is extracted from the `Authorization: Bearer hlk_...` header (or Basic Auth password for Radarr/Sonarr/Prowlarr/Bazarr/Komodo, or GenieKey for TrueNAS) 3. **Transform** β€” the provider handler maps the webhook payload to a **push notification** or **Live Activity** update depending on the event type 4. **Forward** β€” the request is sent to PushWard using the caller's integration key Per-user usage is bounded by your account's plan (free tier monthly quotas; unlimited on paid). ### Push Notifications vs Live Activities Each provider delivers events as either a **push notification** (banner alert) or a **Live Activity** (Dynamic Island + Lock Screen), depending on the event type: | Delivery | Providers | Use case | | --- | --- | --- | | Notification only | Grafana, Prowlarr, Bazarr | One-shot alerts β€” firing/resolved, health, grabs, subtitle downloads | | Live Activity only | ArgoCD, Uptime Kuma, Backrest, Proxmox, Overseerr, Gatus, Changedetection, Paperless, Unmanic, Gitea, Forgejo | Multi-step progress tracking (syncs, downloads, backups) | | Both | Radarr, Sonarr, Jellyfin, Komodo, TrueNAS | Radarr/Sonarr: Grab/Download β†’ Live Activity; Health/Rename/Add/Delete β†’ notification. Jellyfin: playback β†’ Live Activity; library adds, scheduled tasks, auth failures β†’ notification. Komodo: resolvable server conditions (CPU, memory, disk, unreachable, version mismatch, swarm) β†’ Live Activity plus a companion notification; container state, build failed, image update β†’ notification only. TrueNAS: each alert β†’ Live Activity plus a companion notification | β„Ή Info Relay requires PostgreSQL for persistent state (sync tracking, download lifecycle, playback progress across restarts and tenants). Stateless providers (Bazarr, Changedetection, Unmanic) still require a database connection to start. _Requires PushWard iOS app 1.7.0 or later._ ## Poster artwork Four providers carry artwork through to the Live Activity with no configuration on your side. **Jellyfin**, **Radarr**, **Sonarr** and **Overseerr** all know which film or episode an event is about, so the relay resolves its poster and attaches both the image URL and a [ThumbHash](https://pushward.app/docs/live-activities/generic#artwork) to the activity it creates. The Lock Screen shows cover art instead of a generic download glyph. Jellyfin in particular is usually reachable only on your own network, and the phone refuses to download an image from a private host -- so the ~25-byte blurred version travelling on the push is the only thing that renders. The relay fetches the artwork once to build that hash, which is the whole reason this works for a self-hosted media server at all. The webhook waits at most 600ms for a hash it does not already have, then answers without one. The fetch is not cancelled: it carries on under its own ~3s budget and fills the relay-wide cache, so the very first event for a film you have never seen before arrives bare and the next update picks the artwork up. If a poster cannot be resolved at all -- an untagged file, an unreachable image endpoint -- the activity falls back to its icon exactly as before. ### Artwork from a media server on your LAN The relay will not fetch from a private address unless you tell it to. `poster.allow_private_hosts` is `false` by default, so on a stock relay a `http://192.168.1.10:8096/...` or `jellyfin.local` poster produces no ThumbHash -- and since the phone independently refuses that host too, nothing renders. Self-hosting the relay alongside the media server is the case this setting exists for: Relay config: let poster fetches reach the LAN ``` poster: enabled: true allow_private_hosts: true ``` | Environment Variable | Description | Default | | --- | --- | --- | | `PUSHWARD_POSTER_ALLOW_PRIVATE_HOSTS` | Let poster fetches reach loopback, RFC 1918, CGNAT/Tailscale and link-local addresses. It is also what permits a plain http fetch; with it off the relay fetches over https only (YAML: poster.allow\_private\_hosts) | `false` | | `PUSHWARD_POSTER_ENABLED` | Attach poster artwork at all. Turning it off drops both the image URL and the ThumbHash (YAML: poster.enabled) | `true` | ⚠ Warning Leave this off on any relay that accepts webhooks you do not control, including the public `relay.pushward.app`, which runs with the default. The webhook payload is what carries the image URL, so a relay that will fetch private addresses on request is an SSRF probe of everything it can reach. When it is on, the check runs against the _resolved_ address rather than the hostname, so a DNS name pointing at `127.0.0.1` does not slip past it. β„Ή Info The ThumbHash encoder decodes JPEG, PNG and GIF. Artwork served as WebP or AVIF gets an `image_url` but no hash, so it renders only when the phone can reach the URL. ## Per-request overrides Append query parameters to any provider webhook URL to override that provider's delivery behavior for a single request. An explicit parameter always wins over the provider-computed value and your static config; omitting a parameter leaves today's behavior unchanged. | Parameter | Values | Effect | | --- | --- | --- | | `channels` | Comma-separated: `activity`, `notification` | Restricts which delivery surfaces this request may use. `channels=notification` suppresses Live Activities and only sends push notifications; `channels=activity` does the reverse. Must list at least one valid surface. | | `priority` | Integer `0`\-`10` | Sets the eviction / relevance [priority](https://pushward.app/docs/api/activities) for any Live Activity the request creates. | | `level` | `passive`, `active`, `time-sensitive`, `critical` | Sets the notification interruption [level](https://pushward.app/docs/notifications/basics) for any push notification the request sends. | Force a Grafana alert to a time-sensitive notification only ``` https://relay.pushward.app/grafana?channels=notification&level=time-sensitive ``` ⚠ Warning An invalid value is rejected with `400` before the webhook is processed β€” an unknown `channels` surface, a `priority` that isn't an integer `0`\-`10`, or a `level` outside the four allowed values. Fix the URL rather than retrying. ## Setup ### 1\. Get Your Integration Key Use your default integration key from the PushWard app under **Settings β†’ Integration Key**, or [create a scoped key](https://pushward.app/docs/api/authentication#scoped-keys) for this integration. ### 2\. Self-Hosted (Optional) If you prefer to run your own relay instance: docker-compose.yml ``` services: pushward-relay: image: ghcr.io/mac-lucky/pushward-relay:latest ports: - "8090:8090" environment: PUSHWARD_URL: https://api.pushward.app PUSHWARD_DATABASE_DSN: postgres://user:pass@db:5432/relay?sslmode=disable restart: unless-stopped db: image: postgres:17-alpine environment: POSTGRES_USER: user POSTGRES_PASSWORD: pass POSTGRES_DB: relay volumes: - relay-data:/var/lib/postgresql/data volumes: relay-data: ``` ## Authentication Three patterns depending on the provider: | Method | Providers | Format | | --- | --- | --- | | Bearer token | Most providers | `Authorization: Bearer hlk_...` | | Basic Auth | Radarr, Sonarr, Prowlarr, Bazarr, Komodo (their webhook settings only support Basic Auth) | `hlk_...` as password, username ignored | | GenieKey | TrueNAS | `Authorization: GenieKey hlk_...` | ## Configuration | Environment Variable | Description | Default | | --- | --- | --- | | `PUSHWARD_URL` | PushWard server URL | \-- | | `PUSHWARD_DATABASE_DSN` | PostgreSQL connection string | \-- | | `PUSHWARD_SERVER_ADDRESS` | HTTP listen address | `:8090` | Each provider can be individually enabled/disabled and configured with its own priority, cleanup delay, and stale timeout via environment variables or YAML config. --- # Grafana Turn Grafana alerts into a timeline Live Activity on your iPhone Lock Screen. See metric history at a glance with real-time updates while alerts are firing. ![Live Activity demonstration](https://pushward.app/_app/immutable/assets/timeline-poster.CNIwLB3x.webp) Two ways to bring Grafana into PushWard. The **app plugin** below is the recommended path β€” it runs the timeline bridge and a widget engine _inside_ Grafana, with one-click setup. For simple alert-style notifications (firing/resolved, no history), the [Relay](https://pushward.app/docs/integrations/relay) integration needs no plugin or container at all. ## Get Your Integration Key Use your default integration key from the PushWard app under **Settings β†’ Integration Key**, or [create a scoped key](https://pushward.app/docs/api/authentication#scoped-keys) for this integration. ## Grafana App Plugin (Recommended) The PushWard app plugin is the in-Grafana setup and management layer. One click wires up the webhook contact point, validates your key, and renders the live timeline sparkline **inside Grafana** using your existing datasource β€” no separate container and no extra Prometheus config. It can also poll PromQL on a schedule and publish the results as iOS Home and Lock Screen widgets. β„Ή Info Grafana doesn't let third parties add native contact-point types β€” every integration (including Grafana's own OnCall) delivers through a **webhook** contact point. The plugin makes that setup one-click; it doesn't replace the webhook. ### Requirements - Grafana **12.3 or newer** (unified alerting plus app-plugin IAM service accounts) - A PushWard integration key (`hlk_` prefix) β€” the `notifications` capability for test pushes, and `widgets` to publish widgets - A Prometheus or VictoriaMetrics datasource in Grafana (for the timeline history) ### 1\. Install the Plugin Self-hosted today: download the release ZIP from the [plugin repository](https://github.com/mac-lucky/pushward-grafana-plugin/releases), unzip it into your Grafana `plugins/` directory, and allowlist the unsigned plugin: grafana.ini ``` [plugins] allow_loading_unsigned_plugins = pushward-alerts-app ``` Restart Grafana, then enable the app under **Administration > Plugins and data > Plugins > PushWard** and open its **Configuration** page. ### 2\. Connect On the **Connect** page, one click creates the PushWard webhook contact point (no manual URL or header copying) along with a scoped Grafana service-account token, and validates your integration key against the PushWard API. Add that contact point to any alert rule's notification policy to start receiving timelines. ### 3\. Configure On the **Configuration** page (Admin only) you set: - **Integration key** β€” your `hlk_` key - **Datasource** β€” the Prometheus/VictoriaMetrics datasource the backend queries for metric history - **Severity mapping** β€” which alert label drives the accent color and icon - **History window and poll interval** β€” how far back to backfill and how often to refresh while firing - **Also send a push notification** β€” off by default; sends a normal push alongside the quiet timeline when an alert fires and when it resolves, with a **Notification priority** of Silent, Normal, or Critical (Critical needs the critical-alert entitlement on your account, otherwise it falls back to time-sensitive). Plugin 0.6.0+ - **Widgets** β€” the widget definitions described below ### Widget Engine Beyond timelines, the backend can poll PromQL on a schedule and publish the results as iOS widgets. Declare `value`, `progress`, `status`, `gauge`, and `stat_list` widgets in the configuration; each runs on its own interval with an `on_change` or `always` trigger mode, and a multi-series query fans out into one widget update per series. ### Management and Dashboard The **Overview**, **Activities**, and **Widgets** pages show current Live Activities, a recent delivery log, and your declared widgets, and let you send a test notification or fire a test timeline. The **Overview** page also shows delivery counters β€” alerts received, activities created, pushes sent, and errors. Alert rules and instances get a **View in PushWard** action too. ## Standalone Container (Legacy) ⚠ Warning The standalone `pushward-grafana` container will soon be deprecated in favor of the app plugin above, which covers the same timeline bridge plus widgets with no separate deployment. Use it only if you can't run an app plugin on your Grafana. ### How It Works 1. **Webhook** β€” Grafana fires an alert and sends the webhook to pushward-grafana 2. **Query** β€” The service queries Prometheus or VictoriaMetrics for the metric's recent history 3. **Timeline** β€” A Live Activity starts with a sparkline chart showing the metric over time 4. **Poll** β€” While the alert is firing, the service polls for new data points and pushes updates 5. **Resolve** β€” When the alert resolves, the activity ends with a final snapshot ### Deploy the Service docker-compose.yml ``` services: pushward-grafana: image: ghcr.io/mac-lucky/pushward-grafana:latest ports: - "8090:8090" environment: PUSHWARD_URL: https://api.pushward.app PUSHWARD_API_KEY: hlk_YOUR_INTEGRATION_KEY PUSHWARD_METRICS_URL: http://prometheus:9090 # Optional: webhook secret (must match Grafana contact point) # PUSHWARD_WEBHOOK_TOKEN: your-shared-secret # Optional: Grafana API for auto-extracting PromQL from alert rules # PUSHWARD_GRAFANA_URL: http://grafana:3000 # PUSHWARD_GRAFANA_API_TOKEN: glsa_... restart: unless-stopped ``` ### Configure the Grafana Contact Point 1. Navigate to **Alerts & IRM > Alerting > Notification configuration > Contact points** 2. Click **\+ New contact point** 3. Select **Webhook** as the integration type 4. Set URL to your pushward-grafana instance (e.g. `http://pushward-grafana:8090/webhook`) 5. If you set `PUSHWARD_WEBHOOK_TOKEN`, expand **Optional settings** and set Authorization header scheme to `Bearer` with the matching token 6. Click **Test**, then **Save** Create a **Notification policy** that routes alerts to this contact point. ### Configuration | Environment Variable | Description | Default | | --- | --- | --- | | `PUSHWARD_URL` | PushWard server URL | \-- | | `PUSHWARD_API_KEY` | Integration key (`hlk_` prefix) | \-- | | `PUSHWARD_METRICS_URL` | Prometheus or VictoriaMetrics URL | \-- | | `PUSHWARD_WEBHOOK_TOKEN` | Shared secret for webhook authentication | `(none)` | | `PUSHWARD_SERVER_ADDRESS` | HTTP listen address | `:8090` | | `PUSHWARD_PRIORITY` | Activity priority (0-10) | `5` | | `PUSHWARD_CLEANUP_DELAY` | Delay before cleanup after resolved | `15m` | | `PUSHWARD_STALE_TIMEOUT` | Ends activity if no updates received | `24h` | | `PUSHWARD_GRAFANA_URL` | Grafana API URL for auto-extracting PromQL | `(none)` | | `PUSHWARD_GRAFANA_API_TOKEN` | Grafana service account token (Editor role) | `(none)` | | `PUSHWARD_HISTORY_WINDOW` | How far back to query on alert fire | `30m` | | `PUSHWARD_POLL_INTERVAL` | How often to poll for fresh data points | `30s` | The sparkline visual settings have no environment-variable override β€” set them under the `timeline:` block in the config file: `timeline.smoothing` (curve smoothing, default `true`), `timeline.scale` (`linear` or `logarithmic`, default `linear`), and `timeline.decimals` (value precision, default `1`). ### Dashboard Links The bridge forwards the alert's `silenceURL`, `generatorURL`, and panel links straight through to the notification β€” both `http://` and `https://` schemes are accepted, so a self-hosted Grafana on a LAN (e.g. `http://grafana.internal/d/abc`) round-trips cleanly for users on VPN. URLs without an `http(s)://` scheme are dropped on the way in. ## PromQL Resolution The timeline needs a PromQL expression to query metric history. The app plugin reads the firing rule's query from your datasource automatically; the standalone container resolves the query in this order: 1. **Auto-extract** (recommended) β€” If `PUSHWARD_GRAFANA_URL` and `PUSHWARD_GRAFANA_API_TOKEN` are set, the service reads the alert rule's PromQL expression via the Grafana API. No per-rule configuration needed. 2. **Annotations** β€” Add annotations to your Grafana alert rules to specify the query explicitly. 3. **Webhook values** β€” Falls back to the numeric values included in the Grafana webhook payload (no history, just the current value). ### Per-Rule Annotations Add these annotations to your Grafana alert rules to override the query or tune the sparkline: | Annotation | Description | Example | | --- | --- | --- | | `pushward_query` | PromQL expression for history backfill | `avg(rate(http_requests_total[5m]))` | | `pushward_unit` | Unit label shown on the sparkline | `%`, `Β°C`, `ms` | | `pushward_threshold` | Threshold value shown as dashed line | `80` | | `pushward_ref_id` | Which values key to use (default: first) | `B` | ## Severity Colors The sparkline accent color and icon are derived from the `severity` label on the alert rule: | Severity Label | Color | Icon | | --- | --- | --- | | `critical` | Red | Alert octagon | | `warning` | Orange | Alert | | `info` | Blue | Information | πŸ’‘ Tip Add a `severity` label to your alert rules (e.g. `severity = critical`) for automatic color and icon theming. --- # Home Assistant A native HACS integration that bridges Home Assistant entity state changes to PushWard Live Activities on your iPhone. β„Ή Info Unlike other PushWard integrations, Home Assistant uses a custom HACS component β€” no Docker container required. ## Requirements - Home Assistant 2025.7.0 or newer - PushWard iOS app installed on your iPhone - A PushWard integration key (your default key works out of the box) ## Installation ### Via HACS (Recommended) PushWard is in the default HACS store, so there is no custom repository to add. 1. Open HACS in Home Assistant 2. Search for "PushWard" 3. Open it and click **Download** 4. Restart Home Assistant ### Manual Copy `custom_components/pushward/` from the [repository](https://github.com/mac-lucky/pushward-hass) into your Home Assistant's `custom_components/` directory and restart. ## Setup Go to **Settings > Devices & Services > Add Integration > PushWard**. | Field | Description | | --- | --- | | Integration Key | Your `hlk_` integration key (the default key works) | Use your default integration key from the PushWard app under **Settings β†’ Integration Key**, or [create a scoped key](https://pushward.app/docs/api/authentication#scoped-keys) for this integration. ## Adding Entities After setup, click "Add tracked entity" on the integration card. Each entity becomes a Live Activity. | Field | Default | Description | | --- | --- | --- | | Entity | β€” | Any Home Assistant entity | | Activity Slug | Auto (`ha-`) | Unique identifier on PushWard | | Activity Name | Entity ID | Display name shown on iPhone | | Icon | Domain default | SF Symbol name (e.g., `washer`, `thermometer`) or MDI icon with `mdi:` prefix (e.g., `mdi:washing-machine`, `mdi:thermometer`) | | Priority | 1 | 0–10 eviction priority | | Template | `generic` | `generic`, `countdown`, `alert`, `steps`, `gauge`, `timeline`, `board`, or `log` | | Start States | Domain default | Comma-separated states that start the activity | | End States | Domain default | Comma-separated states that end the activity | | Min. Update Interval | 5s | Cooldown between mid-activity updates | | Progress Entity / Attribute | β€” | 0–100 percentage, from the tracked entity or a separate one | | Remaining Time Entity / Attribute | β€” | Remaining seconds, from the tracked entity or a separate one (auto-parses timestamp, duration, `H:MM:SS`, or plain seconds) | | Accent Color | Blue | Color for the Live Activity accent | Each value (remaining time, progress, subtitle, gauge value, current step, fired-at) can read from a **separate entity** or attribute, so an appliance with separate state and time-remaining sensors needs no template helper. The `board` and `log` templates compose several entities into one activity. A **board** shows 1–4 tiles, each reading from a separate entity; a **log** shows a newest-first list of up to 20 lines, with optional extra columns (an attribute or another entity's value) and a per-line severity level. ## Domain Defaults Start/end states and icons are pre-filled based on the entity's domain. The default icons are Material Design (`mdi:`) names, which PushWard accepts alongside SF Symbols: | Domain | Icon | Start States | End States | | --- | --- | --- | --- | | `binary_sensor` | `mdi:toggle-switch-variant` | on | off | | `switch` | `mdi:toggle-switch-variant` | on | off | | `climate` | `mdi:thermostat` | heating, cooling | off, idle | | `vacuum` | `mdi:robot-vacuum` | cleaning | docked, idle | | `media_player` | `mdi:cast` | playing | off, idle, paused | | `lock` | `mdi:lock` | unlocked | locked | | `cover` | `mdi:window-open` | opening, closing | open, closed | | `timer` | `mdi:timer-outline` | active | idle, paused | | `sensor` | `mdi:eye` | _manual_ | _manual_ | ## Activity Lifecycle ### Start The integration listens for state changes on the entities you configure. When an entity enters a **start state** (e.g., a washer turns `on`), it creates the activity on PushWard (if it doesn't exist) and a Live Activity appears on your Lock Screen via push-to-start. ### Updates While active, any state or attribute change β€” on the tracked entity or any configured companion entity β€” triggers a throttled update. Rapid changes are coalesced β€” only the latest state is sent when the cooldown expires. The integration is entirely event-driven, so the update interval is a rate-limiter, not a polling period. ### End When the entity reaches an end state, a two-phase dismissal runs: 1. A "Complete" update is sent (green accent, checkmark icon). The progress bar keeps its last value rather than jumping to 100% -- only the steps and gauge templates fill the bar to its maximum. 2. After 5 seconds, the activity is ended and dismissed from the Lock Screen If the entity starts again during the 5-second window, the end is cancelled. ### HA Restart On Home Assistant restart, any tracked entity already in a start state automatically resumes its Live Activity. ## Notifications, Widgets & Email Beyond mirroring entities to Live Activities, the integration exposes services for the rest of the PushWard surface. Each needs the matching capability on your integration key β€” your default key has all three enabled: - **iOS widgets** β€” add a **tracked widget** sub-entry (event- or poll-triggered, 10–3600Β s) to push Home Screen widgets in the `value`, `progress`, `gauge`, `status`, and `stat_list` templates, or call `pushward.widget_refresh` to force a refresh and `pushward.delete_widget` to remove one. Needs the `widgets` capability. - **Push notifications** β€” the `pushward.send_notification` service sends a regular (non-Live-Activity) [push](https://pushward.app/docs/notifications) with title, body, level (including `critical`), actions, and rich media. Needs the `notifications` capability. - **Transactional email** β€” the `pushward.send_email` service delivers [email](https://pushward.app/docs/email) to a verified recipient of your account. Needs the `emails` capability. For driving an activity straight from an automation (rather than tracking an entity), the create, update, end, and delete services are all available. Update actions are template-specific β€” `pushward.update_activity_generic`, `…_steps`, `…_gauge`, and so on β€” so each action's form only shows the fields that template uses. ## Account Sensors The integration also creates five sensors that track your PushWard usage against your plan, refreshed about every 15 minutes: - `sensor.pushward_notifications_used` - `sensor.pushward_live_activity_updates_used` - `sensor.pushward_widget_updates_used` - `sensor.pushward_emails_used` - `sensor.pushward_subscription_tier` β€” `free` or `premium` ## Example: Washing Machine Track a washing machine using a sensor entity with a progress attribute: | Setting | Value | | --- | --- | | Entity | `sensor.washing_machine_status` | | Icon | `washer` (or `mdi:washing-machine`) | | Template | `generic` | | Start States | `washing, rinsing, spinning` | | End States | `off, complete, idle` | | Progress Attribute | `progress_percent` | | Accent Color | Blue | --- # GitHub Actions Track CI/CD workflow progress in real-time on your Lock Screen using the steps template. ![Live Activity demonstration](https://pushward.app/_app/immutable/assets/github-poster.CIGWInoZ.webp) ## How It Works The `pushward-github` bridge container polls the GitHub Actions API for in-progress workflow runs and maps workflow progress to Live Activities using the steps template. 1. **Idle polling** -- checks all configured repos every 60s for in-progress workflows 2. **Active tracking** -- on each poll cycle the bridge fetches the tracked run's jobs and updates progress 3. **Cleanup** -- after the workflow completes, the activity is cleaned up after a configurable delay (default 15 min) ## Setup ### 1\. Get Your Integration Key Use your default integration key from the PushWard app under **Settings β†’ Integration Key**, or [create a scoped key](https://pushward.app/docs/api/authentication#scoped-keys) for this integration. ### 2\. Create a GitHub Personal Access Token Create a fine-grained PAT with `actions:read` permission on the repos you want to track. ### 3\. Deploy the Bridge docker-compose.yml ``` services: pushward-github: image: ghcr.io/mac-lucky/pushward-github:latest environment: PUSHWARD_URL: https://api.pushward.app PUSHWARD_API_KEY: hlk_YOUR_INTEGRATION_KEY PUSHWARD_GITHUB_TOKEN: github_pat_YOUR_GITHUB_TOKEN PUSHWARD_GITHUB_OWNER: your-username # auto-discovers all repos # OR specify repos explicitly: # PUSHWARD_GITHUB_REPOS: owner/repo1,owner/repo2 restart: unless-stopped ``` ## Configuration | Environment Variable | Description | Default | | --- | --- | --- | | `PUSHWARD_URL` | PushWard server URL | \-- | | `PUSHWARD_API_KEY` | Integration key (`hlk_` prefix) | \-- | | `PUSHWARD_GITHUB_TOKEN` | GitHub PAT with `actions:read` | \-- | | `PUSHWARD_GITHUB_OWNER` | GitHub username for auto-discovery | \-- | | `PUSHWARD_GITHUB_REPOS` | Comma-separated `owner/repo` list | \-- | | `PUSHWARD_PRIORITY` | Activity priority (0-10) | `1` | | `PUSHWARD_POLL_IDLE` | Poll interval for run detection and active job updates | `60s` | | `PUSHWARD_CLEANUP_DELAY` | Delay before cleanup after `ended` | `15m` | πŸ’‘ Tip Use `PUSHWARD_GITHUB_OWNER` to auto-discover all your repos. The bridge refreshes the repo list every 5 minutes, skipping archived and disabled repos. ## Activity Slug Format Activities are created with the slug `gh-<8 hex chars>`, derived from `SHA-256(owner/repo)` (e.g. `gh-1a2b3c4d`). One run is tracked per repository at a time. --- # SABnzbd Track SABnzbd download progress as a Live Activity on your iPhone Lock Screen. See file names, speed, ETA, and post-processing phases in real-time. ![Live Activity demonstration](https://pushward.app/_app/immutable/assets/sabnzbd-poster.4_7r6Srt.webp) ## How It Works The `pushward-sabnzbd` bridge container runs an HTTP server that receives webhooks from SABnzbd and tracks download and post-processing progress. 1. **Webhook** -- SABnzbd notifies the bridge when a download is added 2. **Download tracking** -- polls SABnzbd every 5s (configurable), showing progress, speed (MB/s), ETA, and file name 3. **Post-processing** -- tracks Verifying, Repairing, Extracting, and Moving phases 4. **Queue continuation** -- if more downloads appear, continues tracking 5. **Summary** -- shows total size, duration, and average speed before cleanup ## Setup ### 1\. Get Your Integration Key Use your default integration key from the PushWard app under **Settings β†’ Integration Key**, or [create a scoped key](https://pushward.app/docs/api/authentication#scoped-keys) for this integration. ### 2\. Deploy the Bridge docker-compose.yml ``` services: pushward-sabnzbd: image: ghcr.io/mac-lucky/pushward-sabnzbd:latest ports: - "8090:8090" environment: PUSHWARD_URL: https://api.pushward.app PUSHWARD_API_KEY: hlk_YOUR_INTEGRATION_KEY PUSHWARD_SABNZBD_URL: http://sabnzbd:8080/api PUSHWARD_SABNZBD_API_KEY: your-sabnzbd-api-key restart: unless-stopped ``` ### 3\. Configure SABnzbd Notification Script In SABnzbd, configure a notification script that sends a POST request to the bridge webhook: 1. Go to **Config > Notifications** 2. Under **Notification Script**, add a script that calls the webhook 3. Enable notifications for **Added NZB** Alternatively, use a curl-based script: notify.sh ``` #!/bin/bash curl -X POST http://pushward-sabnzbd:8090/webhook \ -H "X-Webhook-Secret: your-secret" ``` The secret is defined on the bridge container (the `PUSHWARD_SABNZBD_WEBHOOK_SECRET` env var in the Configuration table below) and sent by this script; SABnzbd itself has no field for it. ## Configuration | Environment Variable | Description | Default | | --- | --- | --- | | `PUSHWARD_URL` | PushWard server URL | \-- | | `PUSHWARD_API_KEY` | Integration key (`hlk_` prefix) | \-- | | `PUSHWARD_SABNZBD_URL` | SABnzbd API URL | \-- | | `PUSHWARD_SABNZBD_API_KEY` | SABnzbd API key | \-- | | `PUSHWARD_SABNZBD_WEBHOOK_SECRET` | Optional shared secret. When set, `POST /webhook` requires a matching `X-Webhook-Secret` header. | \-- | | `PUSHWARD_SERVER_ADDRESS` | HTTP listen address | `:8090` | | `PUSHWARD_PRIORITY` | Activity priority (0-10) | `1` | | `PUSHWARD_POLL_INTERVAL` | Poll interval during tracking (min 1s) | `5s` | | `PUSHWARD_CLEANUP_DELAY` | Delay before cleanup after completed | `15m` | | `PUSHWARD_STALE_TIMEOUT` | Ends activity if no updates received | `30m` | | `PUSHWARD_SABNZBD_TEMPLATE` | Display template (`generic` or `timeline`) | `generic` | ## Download Phases | Phase | Live Activity | Color | | --- | --- | --- | | Starting | Starting... | Blue | | Downloading | 12.3 MB/s | Blue | | Paused | Paused | Blue | | Verifying | Verifying... | Orange | | Repairing | Repairing... | Orange | | Extracting | Extracting... | Orange | | Complete | 1.2 GB Β· 45 MB/s avg Β· unpack 2m 3s | Green | πŸ’‘ Tip The bridge automatically resumes tracking if it detects active downloads on startup. ## Templates SABnzbd supports two display templates, configured via `PUSHWARD_SABNZBD_TEMPLATE`: | Template | Display | | --- | --- | | `generic` (default) | Progress bar with speed, ETA, and file name | | `timeline` | Sparkline chart showing download speed over time, with speed (MB/s) as the primary metric | ## Activity Slug Format Uses a fixed slug: `sabnzbd`. All downloads are tracked under a single activity. --- # Bambu Lab Track Bambu Lab 3D print progress as a Live Activity on your iPhone Lock Screen - layer counts, nozzle temperature, and ETA via local MQTT. ![Live Activity demonstration](https://pushward.app/_app/immutable/assets/bambu-poster.CnFQS_70.webp) ## How It Works The `pushward-bambulab` bridge connects directly to your printer over local MQTT (no cloud dependency) and streams print progress to PushWard. 1. **MQTT connection** -- connects to printer on port 8883 using TLS with the printer's access code 2. **State tracking** -- subscribes to printer reports and merges delta updates into a full state 3. **Progress updates** -- sends layer count, nozzle temperature, and progress bar to PushWard every 5s (configurable) 4. **Lifecycle management** -- automatically starts tracking when a print begins and ends when it finishes, fails, or is cancelled ## Setup ### 1\. Enable Developer Mode On your Bambu Lab printer, enable **LAN Only** mode, then turn on **Developer Mode** (Settings > LAN Only > Developer Mode). Developer Mode is the setting that opens the local MQTT channel this bridge connects to -- LAN Only mode on its own may not. ### 2\. Get the Access Code Read the 8-character access code from the printer's screen. The exact location depends on the model: **P1P/P1S** under Settings > WLAN; **X1/X1 Carbon/X1E/H2** under the General tab in Settings; **A1/A1 mini** on the LAN Only Mode screen. If no access code is shown, enable LAN Only mode first. ### 3\. Get Your Integration Key Use your default integration key from the PushWard app under **Settings β†’ Integration Key**, or [create a scoped key](https://pushward.app/docs/api/authentication#scoped-keys) for this integration. ### 4\. Deploy the Bridge docker-compose.yml ``` services: pushward-bambulab: image: ghcr.io/mac-lucky/pushward-bambulab:latest environment: PUSHWARD_URL: https://api.pushward.app PUSHWARD_API_KEY: hlk_YOUR_INTEGRATION_KEY PUSHWARD_BAMBULAB_HOST: 192.168.1.100 PUSHWARD_BAMBULAB_ACCESS_CODE: 12345678 PUSHWARD_BAMBULAB_SERIAL: 01S00A123456789 restart: unless-stopped ``` β„Ή Info No ports need to be exposed -- the bridge connects outbound to both the printer (MQTT) and PushWard API. ## Configuration | Environment Variable | Description | Default | | --- | --- | --- | | `PUSHWARD_URL` | PushWard server URL | \-- | | `PUSHWARD_API_KEY` | Integration key (`hlk_` prefix) | \-- | | `PUSHWARD_BAMBULAB_HOST` | Printer IP address or hostname | \-- | | `PUSHWARD_BAMBULAB_ACCESS_CODE` | 8-character access code from the printer screen (P1P/P1S: Settings > WLAN; X1/H2: General tab; A1/A1 mini: LAN Only Mode screen) | \-- | | `PUSHWARD_BAMBULAB_SERIAL` | Printer serial number (15 characters) | \-- | | `PUSHWARD_BAMBULAB_CERT_FINGERPRINT` | Optional SHA-256 fingerprint of the printer cert (hex, with or without `:` separators). Pins TLS to that exact cert. When unset, the bridge auto-pins on first connect (TOFU). | \-- | | `PUSHWARD_POLL_INTERVAL` | How often to send progress updates | `5s` | | `PUSHWARD_PRIORITY` | Activity priority (0-10) | `1` | | `PUSHWARD_CLEANUP_DELAY` | How long the activity stays after ending | `15m` | | `PUSHWARD_STALE_TIMEOUT` | Ends activity if no updates received | `60m` | To pin explicitly up front (recommended for hardened setups), extract the printer's SHA-256 fingerprint from a trusted network and set `PUSHWARD_BAMBULAB_CERT_FINGERPRINT`: ``` openssl s_client -connect :8883 -servername ` (serial lowercased), e.g. `bambu-01s00a123456789`. One activity per printer. --- # Unraid A native Unraid plugin that forwards every Unraid notification to your iPhone and shows parity checks, backups, mover, and UPS events as Live Activities on your Lock Screen. β„Ή Info **Beta.** The plugin works but expect some rough edges β€” please [report any issues](https://github.com/mac-lucky/pushward-unraid-plugin/issues). ## How It Works A native Unraid plugin β€” no Docker container, and no Unraid API key. It installs a Dynamix notification agent plus a small background monitor that reads Unraid's own state, and talks to the PushWard REST API over HTTPS. 1. **Notifications** β€” the agent forwards every Unraid notification to your iPhone. `ALERT` and `WARNING` map to active iOS pushes, everything else to passive (turn on `INFO` too under **Settings β†’ Notifications**). The full message body is sent, not just the subject. 2. **Live Activities** β€” a background monitor (started by an array-event hook, kept alive by a 1-minute watchdog cron) reads Unraid's state and drives Live Activities for long-running jobs. They appear via push-to-start with no app interaction, update as the job runs, and end automatically when it finishes. | Source | What you see | | --- | --- | | Parity check / rebuild / clear | Percent complete, speed, ETA, and the error count on parity checks | | Appdata backup | Step-by-step progress, one step per container | | Mover | Files listed as they move, with percent and transfer speed | | VM backup | Step-by-step progress, one step per VM | | UPS on battery | Battery charge and runtime countdown while running on battery | The monitor only reads status β€” it never touches Unraid or the source plugins β€” and stays under your PushWard update quota by pushing only on a meaningful change. A few notes on the file-level sources: the mover lists files only when **Mover logging** is on (**Settings β†’ Scheduler β†’ Mover Settings**); with it off you get a percent and bytes bar instead. VM backup tracks the `vmbackup` plugin's own scheduled or manual runs. The UPS source reads `apcupsd` via `apcaccess` and only appears while on battery (NUT is not supported). ## Setup ### 1\. Get Your PushWard Integration Key Use your default integration key from the PushWard app under **Settings β†’ Integration Key**, or [create a scoped key](https://pushward.app/docs/api/authentication#scoped-keys) for this integration. One `hlk_` key covers both features. Notifications need the `notifications` capability and work on any plan; Live Activities need the `activity:manage` scope and an active PushWard subscription. ### 2\. Install the Plugin In the Unraid web UI, open **Plugins β†’ Install Plugin** and paste: ``` https://github.com/mac-lucky/pushward-unraid-plugin/raw/main/pushward-unraid.plg ``` Unraid 6.12 or later is required. ### 3\. Configure Open **Settings β†’ PushWard** (in the **User Utilities** row). On the **Settings** tab, fill in: | Field | Description | Default | | --- | --- | --- | | Live Activities | Master switch for progress activities | `On` | | PushWard server URL | PushWard API URL | `https://api.pushward.app` | | PushWard API key | The `hlk_…` key from step 1 | \-- | | Server display name | Shown under notifications and used to name activities (e.g. "Tower") | `Unraid` | | Track parity / appdata backup / mover / VM backup / UPS | Per-source toggles for the Live Activities above | `On` | | Poll interval | How often the monitor checks state | `15s` | | Activity priority | 0–10, used when PushWard evicts to make room for higher-priority activities | `5` | Click **Apply**, then use the test buttons to confirm each path. The Settings tab shows live status β€” whether the key is valid, the subscription is active, and the monitor is running. Values are saved under `/boot/config/plugins/pushward-unraid/` and survive reboots and plugin upgrades. The **Activities** tab lists the current Live Activities and lets you end them. ## Logs ``` tail -f /var/log/pushward-monitor.log ``` That follows the Live Activity monitor. Notification-delivery problems are logged to the Unraid system log under the `pushward` tag. ## Updates The plugin uses a date-based version (e.g. `2026.07.05`). Pick up new releases via **Plugins β†’ Check for Updates** inside Unraid. ## Uninstall **Plugins β†’ PushWard β†’ Remove**. The agent, monitor, settings and dashboard pages, and cron are removed, and any active Live Activities are ended. Your config under `/boot/config/plugins/pushward-unraid/` is left intact, so reinstalling restores your API key. --- # ArgoCD Watch ArgoCD application syncs as a Live Activity on your iPhone Lock Screen β€” a three-step pipeline from Syncing through Rolling out to Deployed, with sync failures and health regressions flagged in real time. β„Ή Info ArgoCD delivers through the hosted [Relay](https://pushward.app/docs/integrations/relay) β€” point ArgoCD's built-in notifications at `relay.pushward.app/argocd` with your integration key. No container to deploy and no self-hosting. ## Get Your Integration Key Use your default integration key from the PushWard app under **Settings β†’ Integration Key**, or [create a scoped key](https://pushward.app/docs/api/authentication#scoped-keys) for this integration. ## How It Works ArgoCD's notifications controller posts a small JSON body to the relay on each lifecycle event. The relay maps them onto one [steps](https://pushward.app/docs/live-activities/steps) Live Activity per application β€” a three-step pipeline: | Event | Step | | --- | --- | | `sync-running` | 1/3 β€” Syncing | | `sync-succeeded` | 2/3 β€” Rolling out | | `deployed` | 3/3 β€” Deployed (ends the activity) | | `sync-failed` | Sync Failed | | `health-degraded` | Degraded (transient warning during rollout) | The activity slug is derived from the app name (the `pushward-server` app becomes `argocd-pushward-server`), so repeated syncs of the same app reuse one activity instead of stacking up. Fast syncs that finish within the relay's grace period (10 seconds by default) are suppressed entirely, so a quick no-op sync never wakes your phone. ## Configure ArgoCD Notifications First store your `hlk_` key in `argocd-notifications-secret` so it never lands in a committed manifest: argocd-notifications-secret.yaml ``` apiVersion: v1 kind: Secret metadata: name: argocd-notifications-secret stringData: pushward-key: hlk_YOUR_INTEGRATION_KEY ``` Then add the PushWard webhook service, one template per event, and the matching triggers to `argocd-notifications-cm`. Each template sends the JSON body the relay expects (`app`, `event`, `revision`, `repo_url`) with the event name as a literal string: argocd-notifications-cm.yaml ``` apiVersion: v1 kind: ConfigMap metadata: name: argocd-notifications-cm data: service.webhook.pushward: | url: https://relay.pushward.app/argocd headers: - name: Authorization value: Bearer $pushward-key - name: Content-Type value: application/json template.pushward-sync-running: | webhook: pushward: method: POST body: | {"app":"{{.app.metadata.name}}","event":"sync-running","revision":"{{.app.status.sync.revision}}","repo_url":"{{.app.spec.source.repoURL}}"} template.pushward-sync-succeeded: | webhook: pushward: method: POST body: | {"app":"{{.app.metadata.name}}","event":"sync-succeeded","revision":"{{.app.status.sync.revision}}","repo_url":"{{.app.spec.source.repoURL}}"} template.pushward-deployed: | webhook: pushward: method: POST body: | {"app":"{{.app.metadata.name}}","event":"deployed","revision":"{{.app.status.sync.revision}}","repo_url":"{{.app.spec.source.repoURL}}"} template.pushward-sync-failed: | webhook: pushward: method: POST body: | {"app":"{{.app.metadata.name}}","event":"sync-failed","revision":"{{.app.status.sync.revision}}","repo_url":"{{.app.spec.source.repoURL}}"} template.pushward-health-degraded: | webhook: pushward: method: POST body: | {"app":"{{.app.metadata.name}}","event":"health-degraded","revision":"{{.app.status.sync.revision}}","repo_url":"{{.app.spec.source.repoURL}}"} trigger.on-pushward-sync-running: | - when: app.status.operationState != nil and app.status.operationState.phase in ['Running'] oncePer: app.status.operationState.startedAt send: [pushward-sync-running] trigger.on-pushward-sync-succeeded: | - when: app.status.operationState != nil and app.status.operationState.phase in ['Succeeded'] oncePer: app.status.operationState.startedAt send: [pushward-sync-succeeded] trigger.on-pushward-deployed: | - when: app.status.operationState != nil and app.status.operationState.phase in ['Succeeded'] and app.status.health.status == 'Healthy' oncePer: app.status.operationState.startedAt send: [pushward-deployed] trigger.on-pushward-sync-failed: | - when: app.status.operationState != nil and app.status.operationState.phase in ['Error', 'Failed'] oncePer: app.status.operationState.startedAt send: [pushward-sync-failed] trigger.on-pushward-health-degraded: | - when: app.status.health.status == 'Degraded' send: [pushward-health-degraded] ``` β„Ή Info `oncePer: app.status.operationState.startedAt` makes every sync fire the full event sequence β€” without it ArgoCD deduplicates repeat notifications and later syncs never reach your phone. Finally, subscribe the applications you want to track β€” either per `Application` or on the ArgoCD project to cover everything in it: application.yaml ``` metadata: annotations: notifications.argoproj.io/subscribe.on-pushward-sync-running.pushward: "" notifications.argoproj.io/subscribe.on-pushward-sync-succeeded.pushward: "" notifications.argoproj.io/subscribe.on-pushward-deployed.pushward: "" notifications.argoproj.io/subscribe.on-pushward-sync-failed.pushward: "" notifications.argoproj.io/subscribe.on-pushward-health-degraded.pushward: "" ``` πŸ’‘ Tip Trigger a sync of any subscribed application and watch it move through Syncing, Rolling out, and Deployed on your Lock Screen. If nothing arrives, check the notifications controller logs β€” a wrong `event` string is reported by the relay as an unknown event. --- # REST API Reference for iOS Live Activities The PushWard API is a REST API for managing iOS Live Activities via Apple Push Notification service (APNs). ## Base URL ``` https://api.pushward.app ``` ## Authentication All endpoints (except `/health`) require a Bearer token in the `Authorization` header: ``` Authorization: Bearer hlk_aBcDeFgHiJkLmNoPqRsTuVwXyZ012345 ``` Tokens are `hlk_` integration keys created in the iOS app's settings screen β€” see [Authentication](https://pushward.app/docs/api/authentication) for the key format, scopes, and slug-restriction rules. ## Activity State Machine Activities follow a state machine with two client-visible states: | State | Description | | --- | --- | | `ended` | Inactive (default on creation) | | `ongoing` | Active Live Activity running on device | ### State Transitions Transitions are triggered via `PATCH /activities/{slug}`: | From | To | Push Action | | --- | --- | --- | | `ended` | `ongoing` | Push-to-start (starts Live Activity) | | `ongoing` | `ongoing` | Push update (updates content) | | `ongoing` | `ended` | Push end (dismisses after 4 hours) | β„Ή Info Activities may also enter an internal `preempted` state when the server evicts the lowest-priority activity to stay under the ongoing limit. Clients cannot set `preempted` directly β€” patch the activity back to `ongoing` to restart it. Rate limits, quotas, and the eviction rule live on the [Limits](https://pushward.app/docs/limits) page. ## Endpoints | Section | Description | | --- | --- | | [Authentication](https://pushward.app/docs/api/authentication) | Token management and user profile | | [Activities](https://pushward.app/docs/api/activities) | Create, list, update, and delete activities | | [Notifications](https://pushward.app/docs/notifications/api) | Send inbox notifications with optional APNs push delivery | | [Widgets](https://pushward.app/docs/widgets/api) | Create, update, and delete Home Screen widgets | | [Email](https://pushward.app/docs/email) | Send transactional email to verified recipients | --- # Authentication Bearer authentication with integration keys (`hlk_`): signing in with Apple ID creates a default key, and you can add scoped keys per service. ## Token Format A key is `hlk_` followed by 32 base62 characters (~36 characters total). Only the SHA-256 hash is stored server-side, so a lost key cannot be recovered. ``` Authorization: Bearer hlk_aBcDeFgHiJkLmNoPqRsTuVwXyZ012345 ``` ## Scopes | Scope | Access | | --- | --- | | `activity:update` (default) | Update existing activities and read state. Cannot create or delete activities. | | `activity:manage` | Everything `activity:update` can do, plus create and delete activities. | Each key can additionally restrict access to specific activity slugs or prefix patterns (trailing `*`) and toggle three independent permission flags: - `notifications` β€” permit `POST /notifications`. - `widgets` β€” permit the [widgets API](https://pushward.app/docs/widgets/api) (`POST/GET/PATCH/DELETE /widgets`). Off by default; toggle per key in the iOS app's integration-keys screen. - `emails` β€” permit [sending email](https://pushward.app/docs/email) (`POST /emails`). Independent of `notifications`; toggle per key in the iOS app's integration-keys screen. ## Creating scoped keys The default key is `activity:manage` with every capability enabled, which is all most integrations need. To limit a service to just its own activities, create a dedicated key restricted to a scope and slug pattern: 1. Open **Settings β†’ Manage Keys** in the iOS app β€” the same screen revokes existing keys. 2. Tap **+**. 3. Set a name, pick a scope (`activity:update` or `activity:manage`), toggle the capability flags the service needs, and optionally restrict the key to a slug pattern such as `grafana-*`. 4. Copy the generated `hlk_` key and store it securely -- it is shown only once. ## Endpoints GET `/auth/me` Get the current user's profile, activity count, and current quota usage. **Response:** ``` { "id": "550e8400-e29b-41d4-a716-446655440000", "nickname": "Alice", "activity_count": 3, "subscribed": false, "quota_period_month": 202606, "notifications_used": 128, "notifications_limit": 500, "live_activity_updates_used": 86, "live_activity_updates_limit": 250, "widget_updates_used": 12, "widget_updates_limit": 50, "emails_used": 3, "emails_limit": 50, "quota_resets_at": "2026-07-01T00:00:00Z" } ``` β„Ή Info Integration keys read their own live usage here β€” the iOS app polls `/auth/me` for its Usage screen and integrations (e.g. Home Assistant) surface the same counters. A `*_limit` field is omitted when that resource is uncapped on your tier. (Detailed subscription fields are only returned to the app's own session, not to integration keys.) ### Response Fields | Field | Type | Description | | --- | --- | --- | | `id` | string | User ID | | `nickname` | string | null | Display name | | `activity_count` | integer | Number of activities owned by the user | | `subscribed` | boolean | Whether the user has an active subscription | | `quota_period_month` | integer | `YYYYMM` bucket for the current monthly usage period (UTC) | | `notifications_used` / `notifications_limit` | integer | Notifications sent this period and your tier's cap. `*_limit` is omitted when uncapped. | | `live_activity_updates_used` / `live_activity_updates_limit` | integer | Live Activity updates this month and your tier's cap | | `widget_updates_used` / `widget_updates_limit` | integer | Widget updates this month and your tier's cap | | `emails_used` / `emails_limit` | integer | Emails sent this month and your tier's cap (free 50, paid 200) | | `quota_resets_at` | string | ISO 8601 instant the active counters reset | ## Access Control | Access Level | Endpoints | | --- | --- | | **No auth** | `GET /health` | | **`hlk_` with `activity:update`** | `PATCH /activities/{slug}` (owned activities only), `GET /activities`, `GET /activities/{slug}`, `POST /notifications` (if the key has the `notifications` flag), `GET /auth/me` | | **`hlk_` with `activity:manage`** | All of the above, plus `POST /activities`, `DELETE /activities/{slug}` | | **`hlk_` with `widgets` flag** | `POST /widgets`, `GET /widgets`, `GET /widgets/{slug}`, `PATCH /widgets/{slug}`, `DELETE /widgets/{slug}` | | **`hlk_` with `emails` flag** | `POST /emails` | --- # Activities Create, update, and end the activities behind every Live Activity with plain REST calls: POST to start, PATCH to update, DELETE to end. β„Ή Info Each user can have a maximum of **50 activities**. Attempting to create more returns `409` with a [Problem Details](#errors) body whose `code` is `"activity.limit_exceeded"` and a `Retry-After` header. ## Create Activity POST `/activities` Create a new activity. Starts in \`ended\` state. All user devices are automatically subscribed. ### Request Body | Field | Type | Required | Description | | --- | --- | --- | --- | | `slug` | string | Yes | URL-safe identifier, unique per user | | `name` | string | Yes | Human-readable display name | | `priority` | integer | No | Eviction priority 0-10 (default: 0). Higher = kept longer server-side and ordered ahead of other Live Activities on the iOS Lock Screen and Dynamic Island (mapped to APNs `relevance-score`). | | `ended_ttl` | integer | No | Seconds after `ended` transition before server auto-deletes the activity | | `stale_ttl` | integer | No | Seconds of inactivity while `ongoing` before server auto-ends the activity | | `dismissal_ttl` | integer | No | Seconds after `ended` before the Live Activity is removed from the Lock Screen. `0` removes it immediately; max `14400` (4h, the iOS ceiling). When unset, removal follows `ended_ttl` β€” see [Lock Screen dismissal](#dismissal) | Example ``` curl -X POST https://api.pushward.app/activities \ -H "Authorization: Bearer hlk_YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "slug": "dishwasher", "name": "Dishwasher", "priority": 3 }' ``` **Response (always `201 Created`):** ``` { "id": "550e8400-e29b-41d4-a716-446655440000", "kind": "owned", "slug": "dishwasher", "name": "Dishwasher", "state": "ended", "priority": 3, "content": {}, "ended_ttl": null, "stale_ttl": null, "dismissal_ttl": null, "delete_at": null, "created_at": "2025-06-15T10:30:00Z", "updated_at": "2025-06-15T10:30:00Z", "ended_at": null } ``` β„Ή Info Re-POSTing the same `slug` is idempotent: the server refreshes the existing activity's `name` / `priority` / TTLs (state and content are preserved) and still returns **201 Created**, never `409`. The `X-Resource-Action` response header says which happened β€” `created` (fresh row) or `updated` (existing slug) β€” so integration bridges can retry safely on network errors. ## List Activities GET `/activities` List activities for the current user. Returns a cursor-paginated envelope sorted by slug. ### Query Parameters | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `limit` | integer | 50 | Page size, between `1` and `100`. | | `after` | string | β€” | Opaque base64url cursor returned as `next_cursor` on the previous page. Omit for the first page. | | `state` | string | β€” | Filter on activity state. One of `ongoing`, `ended`, `preempted` (lowercase β€” the same casing response bodies use). Unknown values are rejected with `422`. Omit to return all states. | Example ``` curl "https://api.pushward.app/activities?limit=50" \ -H "Authorization: Bearer hlk_YOUR_TOKEN" ``` **Response (200):** ``` { "items": [ { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "kind": "owned", "slug": "ci-pipeline", "name": "CI Pipeline", "state": "ended", "priority": 0, "content": {}, "ended_ttl": 3600, "stale_ttl": null, "dismissal_ttl": null, "delete_at": "2025-06-15T12:00:00Z", "created_at": "2025-06-15T10:00:00Z", "updated_at": "2025-06-15T11:00:00Z", "ended_at": "2025-06-15T11:00:00Z" }, { "id": "550e8400-e29b-41d4-a716-446655440000", "kind": "owned", "slug": "dishwasher", "name": "Dishwasher", "state": "ongoing", "priority": 3, "content": { "template": "generic", "progress": 0.65, "state": "Washing", "icon": "washer", "remaining_time": 1800, "subtitle": "Cycle 2 of 3", "accent_color": "blue" }, "ended_ttl": null, "stale_ttl": null, "dismissal_ttl": null, "delete_at": null, "created_at": "2025-06-15T10:30:00Z", "updated_at": "2025-06-15T11:00:00Z", "ended_at": null } ], "next_cursor": "eyJzIjoib3Zlbi10aW1lciIsImkiOjE5fQ" } ``` β„Ή Info Pass `next_cursor` back as `after` until it comes back `null` or empty. Treat the cursor as opaque β€” don't parse or construct it β€” and keep `limit` (and any filter params) identical across pages. It encodes a stable `(slug, id)` pair, so paging stays consistent even if items are added or removed between requests. ``` # Fetch the next page curl "https://api.pushward.app/activities?limit=50&after=eyJzIjoib3Zlbi10aW1lciIsImkiOjE5fQ" \ -H "Authorization: Bearer hlk_YOUR_TOKEN" ``` ## Get Activity GET `/activities/{slug}` Get a single activity by slug. Example ``` curl https://api.pushward.app/activities/dishwasher \ -H "Authorization: Bearer hlk_YOUR_TOKEN" ``` ## Delete Activity DELETE `/activities/{slug}` Delete an activity and all associated subscriptions. Example ``` curl -X DELETE https://api.pushward.app/activities/dishwasher \ -H "Authorization: Bearer hlk_YOUR_TOKEN" ``` **Response:** `204 No Content` Deleting an `ongoing` activity force-dismisses the Live Activity from the Lock Screen and Dynamic Island immediately β€” the push-end carries a dismissal date in the past β€” regardless of `ended_ttl`, which only applies to natural `ongoing β†’ ended` transitions. Deleting an already-`ended` activity only cleans up the database row; the on-screen lifetime was fixed by the dismissal date sent when it ended, so a later `DELETE` cannot shorten it. ## Update Activity (Primary Integration Endpoint) PATCH `/activities/{slug}` Partial update using RFC 7396 JSON Merge Patch semantics. State transitions automatically trigger push notifications to subscribed devices. ### Request Body | Field | Type | Required | Description | | --- | --- | --- | --- | | `state` | string | No | `"ongoing"` or `"ended"`. If omitted, the current stored state is kept. Required when the activity is `preempted` β€” the caller must explicitly set `ongoing` or `ended`. | | `content` | object | No | Partial template content. Fields present overwrite stored values; fields set to `null` clear them; absent fields are preserved. See [Live Activities](https://pushward.app/docs/live-activities). | | `priority` | integer | No | Update eviction priority (0-10). Also drives APNs `relevance-score`, so higher-priority activities are ordered ahead of others on the iOS Lock Screen and Dynamic Island. | | `sound` | string | No | Optional Live Activity alert sound. Plays on Lock Screen and Dynamic Island and marks the push `time-sensitive` so it breaks through Focus modes. Request-scoped (not persisted) and ignored on `ended` transitions. One of `default`, `chime`, `alert`, `success`, `warning`, `bell`, `ding`, `buzz`, `notification`. | ### Merge-patch semantics Updates follow [RFC 7396 JSON Merge Patch](https://www.rfc-editor.org/rfc/rfc7396): absent fields are preserved, explicit `null` clears a field, present values overwrite, and arrays are replaced wholesale β€” re-send the full array, not a delta. The canonical request `Content-Type` is `application/merge-patch+json`; `application/json` is also accepted. - Once set, `alarm` and `warning_threshold` persist across updates until explicitly cleared with `null`. - If both `end_date` and `duration` are sent, `end_date` wins. `duration` accepts integer seconds (`60`) or a string (`"60s"`, `"5m"`, `"1h30m"`). - Transitioning to `ended` clears `alarm`, `snoozed_until`, and `warning_pushed` on the server. Server-owned fields (`warning_pushed`, `snoozed_until`) are stripped from incoming patches. ### Query Parameters | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `upsert` | boolean | `false` | When `true` and the slug doesn't exist yet, the server creates the activity (its `name` defaults to the slug) and applies the patch in the same request, instead of returning `404`. | β„Ή Info An upsert-created slug returns `201 Created` with `X-Resource-Action: created`; an existing slug behaves like a normal patch (`200 OK`). Creation requires the `activity:manage` [scope](https://pushward.app/docs/api/authentication#scopes) β€” an `activity:update` key gets `403` (`activity.upsert_forbidden`) for a missing slug. Alternatively, `POST /activities` is itself an idempotent upsert, so **POST then PATCH** achieves the same in two requests without the flag. Create-and-update in one call ``` curl -X PATCH "https://api.pushward.app/activities/dishwasher?upsert=true" \ -H "Authorization: Bearer hlk_YOUR_TOKEN" \ -H "Content-Type: application/merge-patch+json" \ -d '{ "state": "ongoing", "content": { "template": "generic", "progress": 0.1, "state": "Washing", "icon": "washer", "accent_color": "blue" } }' ``` ### State Transitions | From | To | Push Action | | --- | --- | --- | | `ended` | `ongoing` | Push-to-start (starts Live Activity) | | `ongoing` | `ongoing` | Push update (updates running activity) | | `ongoing` | `ended` | Push end (dismisses after 4 hours) | Example: Start a generic activity ``` curl -X PATCH https://api.pushward.app/activities/dishwasher \ -H "Authorization: Bearer hlk_YOUR_TOKEN" \ -H "Content-Type: application/merge-patch+json" \ -d '{ "state": "ongoing", "content": { "template": "generic", "progress": 0.65, "state": "Washing", "icon": "washer", "remaining_time": 1800, "subtitle": "Cycle 2 of 3", "accent_color": "blue" } }' ``` **Response (200):** The response is the unified `Activity` object β€” the same shape returned by GET, list, and POST. The sharing fields (`share_role`, `owner_id`, `owner_nickname`, `share_count`) use `omitempty`: they are omitted when they don't apply, not sent as `null`. Integration (`hlk_`) keys never receive shared activities, so these fields don't appear in their responses and aren't listed in the [API reference](https://pushward.app/api). ## Content Object Shared fields on `content`; each template adds its own required fields on top (see [Live Activities](https://pushward.app/docs/live-activities)): | Field | Type | Description | | --- | --- | --- | | `template` | string | **Required.** One of: `generic`, `countdown`, `steps`, `alert`, `gauge`, `timeline`, `board`, `log`, `media`, `approval` | | `progress` | float | Optional. Value between `0.0` and `1.0`. Computed automatically for `gauge` from `value`/`min_value`/`max_value`. | | `alarm` | boolean | Optional. Opt-in iOS AlarmKit alarm at `end_date`. Rings through silent mode and Focus. Requires `end_date`; iOS 26+ only. See [Alarms](#alarm). | | `snooze_seconds` | integer | Optional. How many seconds `POST /activities/{slug}/snooze` extends `end_date`, and the iOS AlarmKit snooze window. `60`–`3600`; defaults to `300` (5 min) when omitted. Only meaningful with `alarm: true`. | | `snoozed_until` | integer | Read-only. Unix timestamp set when the user snoozes an alarm from iOS. When non-null and in the future, iOS renders a "Snoozed" banner; it auto-clears when the client sees `snoozed_until ≀ now`. Any client-supplied value is stripped on `PATCH`. | | `tiles` | array | Required for the `board` template. 1–4 tile objects. See [Board](https://pushward.app/docs/live-activities/board). | | `lines` | array | Required for the `log` template. 1–20 line objects, newest first. See [Log](https://pushward.app/docs/live-activities/log). | | `log_backlog` | array | Read-only (`log` template). Server-kept scroll-back history, returned only on `GET /activities/{slug}?include=log_backlog`. Never sent by clients. | | `media_title`, `playback_state`, `position_seconds`, `duration_seconds`, `position_at`, `volume`, `favorite` | mixed | `media` template only; `422` on any other. The track, its playback state (`playing` / `paused` / `stopped` / `buffering`), the sampled position and when it was sampled, the length, and the volume/favorite indicators. See [Media](https://pushward.app/docs/live-activities/media). | | `controls` | object | `media` template only. Named transport slots (`previous`, `play_pause`, `play`, `pause`, `next`, `stop`, `favorite`, `volume_down`, `volume_up`) plus up to 3 `extra` buttons, each an [Action](#tap-actions) object. `http(s)` URLs are silent webhooks (`method` defaults to `POST`, `foreground: true` is a `422`). Deep-merges on `PATCH`; `{"stop": null}` removes a slot. See [Media controls](https://pushward.app/docs/live-activities/media#controls). | | `options` | array | **Required** for the `approval` template; `422` on any other. 2–4 answer buttons (`id`, `title`, optional `style` / `icon` / `url` / `method` / `headers` / `body`); an option without a `url` is server-recorded via a signed answer URL. Replaces wholesale on `PATCH` and clears the stored `answer`. See [Approval](https://pushward.app/docs/live-activities/approval). | | `source` | string | `approval` template only. Who is asking β€” a badge in the card header. Max 24 characters. | | `details` | array | `approval` template only. Up to 2 label/value context rows between the header and the buttons; `label` max 24, `value` max 64 characters. Replaces wholesale on `PATCH`. | | `on_expire` | string | `approval` template only. Option id the server records when nobody has answered by `end_date`, or `none` to just expire. Requires `end_date`. | | `answer` | object | Read-only (`approval` template). `{"option", "at", "by"}` β€” the recorded answer. Ignored on write; cleared when a patch re-sends `options`. See [Approval](https://pushward.app/docs/live-activities/approval). | ## Tap actions Every template accepts up to three optional tap targets on its `content`: | Field | Where the tap target appears | | --- | --- | | `tap_action` | The Live Activity background β€” tapping anywhere outside a button. | | `url_action` | The primary button (replaces the legacy `url` string). | | `secondary_url_action` | The secondary button (replaces the legacy `secondary_url` string). | All three accept the same **Action** object: | Field | Type | Description | | --- | --- | --- | | `url` | string | **Required.** Max 2,048 chars. Any scheme except `javascript:`, `data:`, `file:`, `vbscript:`. `http(s)` URLs require a host; custom schemes (e.g. `youtube://`, `homeassistant://`) open the matching app on the user's device. | | `foreground` | boolean | `http(s)` only. `true` opens the URL in Safari / the in-app browser. `false` (combined with at least one of `method` / `headers` / `body`) fires the request silently from the widget process without launching the app. Ignored for custom schemes. | | `method` | string | HTTP method for a silent webhook. One of `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`. Defaults to `GET`. Ignored for custom schemes. | | `headers` | object | HTTP headers for a silent webhook. ≀1,024 bytes total across keys and values. Ignored for custom schemes. | | `body` | string | HTTP request body for a silent webhook. ≀1,024 chars. Ignored for custom schemes. | | `title` | string | Button label. Max 64 chars. Only meaningful on `url_action` / `secondary_url_action` β€” ignored on `tap_action`. | | `icon` | string | SF Symbol name (e.g. `checkmark.circle`). Max 64 chars. Only meaningful on `url_action` / `secondary_url_action`. | ### Dispatch modes iOS picks a mode from the object's shape: 1. **Custom scheme** (e.g. `homeassistant://`, `youtube://`) β†’ calls `openURL` to launch the target app. `foreground`, `method`, `headers`, and `body` are ignored. 2. **`http(s)` with `foreground: true`** β†’ opens the URL in Safari / the in-app browser. The app is brought to the foreground. 3. **`http(s)` with at least one of `method` / `headers` / `body`** (and `foreground` absent or `false`) β†’ fires the HTTP request **silently** from the Live Activity widget process. The user's app stays in the background; the request runs inside the widget extension under tight time and memory limits. 4. **Bare `{"url":"https://…"}`** without any HTTP shape β†’ treated like #2 (opens Safari). Without an explicit HTTP shape there is no silent-dispatch intent, so the absent `foreground` key is interpreted as "open this". β„Ή Info **Silent webhooks are best-effort.** They run inside the iOS widget extension under tight time and memory limits. Treat them as fire-and-forget acknowledgements β€” the device discards the response. For anything that needs reliable delivery or a return value, use `foreground: true` so your own app or backend handles the work. ### Silent-only buttons The button-bearing templates β€” `media.controls` and `approval.options` β€” treat an `http(s)` URL as a silent webhook _always_: the app stays in the background, the response is discarded, and `method` defaults to `POST`. Sending `foreground: true` there returns `422`; a custom-scheme URL opens that app instead. ### Back-compat with string `url` / `secondary_url` The legacy string fields `url` and `secondary_url` on each template are still accepted β€” older integrations don't need to change. Whenever `url_action` is set, the server omits the legacy `url` string from the APNs payload entirely (and likewise for `secondary_url` vs `secondary_url_action`) β€” emitting both blows past the 4Β KB APNs cap. Newer iOS clients read the structured action; older clients fall back to the legacy string only when the structured action isn't sent. ### Example A Grafana alert activity that lets the user acknowledge with one tap (silent webhook), open the panel in Safari (foreground), and deep-link into the Grafana iOS app when tapping the background of the Live Activity: Alert with primary, secondary, and background tap actions ``` { "state": "ongoing", "content": { "template": "alert", "progress": 0.0, "state": "CPU usage is 94.2%", "icon": "exclamationmark.triangle.fill", "subtitle": "Grafana Β· nas-01", "severity": "warning", "fired_at": 1750009000, "accent_color": "red", "tap_action": { "url": "grafana://alerts/1afz29v7z" }, "url_action": { "url": "https://hooks.example.com/grafana/ack", "method": "POST", "headers": { "Authorization": "Bearer hooks_xxx" }, "body": "{\"alert\":\"1afz29v7z\",\"acked\":true}", "title": "Acknowledge", "icon": "checkmark.circle" }, "secondary_url_action": { "url": "https://grafana.example.com/d/abc123?viewPanel=1", "foreground": true, "title": "Open panel", "icon": "chart.bar" } } } ``` ## Server-Side TTL | Field | Set On | Behavior | | --- | --- | --- | | `stale_ttl` | Create / PATCH | If an `ongoing` activity receives no updates for this many seconds, the server auto-ends it | | `ended_ttl` | Create / PATCH | When an activity transitions to `ended`, the server schedules auto-deletion after this many seconds and β€” unless `dismissal_ttl` is set β€” tells iOS to dismiss the Live Activity from the lock screen at the same moment (capped at the iOS 4-hour limit). Without it, iOS keeps the ended activity on the lock screen for the full 4-hour default. | | `dismissal_ttl` | Create / PATCH | Seconds after `ended` before iOS removes the Live Activity from the Lock Screen: `0` = immediately, max `14400` (4h). Overrides the `ended_ttl`\-derived dismissal without touching auto-deletion. Ignored when the server preempts an activity to free a Live Activity slot β€” preempted cards keep the default linger so they stay glanceable. | | `delete_at` | Auto | Computed timestamp (read-only). Set automatically from `ended_ttl` when state becomes `ended` | | `ended_at` | Auto | Server-stamped timestamp (read-only). Set the first time state transitions to `ended` and preserved across later transitions, so callers can tell when the most recent end happened independently of `updated_at`. `null` until the activity has ended at least once. | When `stale_ttl` expires, the server auto-ends the activity with a distinct visual state β€” `content.state` becomes **"Stale (auto-ended)"**, `accent_color` `#8E8E93` (system gray), `icon` `clock.badge.xmark` β€” and sends the push-end to all subscribed devices. The same expiration is sent to iOS as the APNs `stale-date`, so the Lock Screen presentation is marked stale at the same moment; if `ended_ttl` is also set, its auto-delete timer starts from that point. ### Lock Screen dismissal & linger When a Live Activity ends, iOS doesn't remove it instantly. It keeps a de-emphasized, dimmed copy on the Lock Screen for a while so the user can still see the final state (the "Done", the final score, the delivered package). How long that ended card lingers is controlled by the APNs **dismissal date**, which PushWard derives from `dismissal_ttl` when set, or from `ended_ttl` otherwise. This is PushWard's answer to Apple's `ActivityUIDismissalPolicy`: **yes, it's supported** β€” you set it per activity through `dismissal_ttl` rather than in client code. - **`dismissal_ttl: 0`** β€” the card disappears from the Lock Screen the moment the activity ends (Apple's `.immediate`). Works for client-sent ends _and_ server auto-ends (`stale_ttl`, countdown expiry), and doesn't delete anything server-side. - **`dismissal_ttl: N`** (up to `14400`) β€” the card lingers `N` seconds after ending, independent of when the database row is deleted. - **Unset `dismissal_ttl`, with `ended_ttl`** β€” legacy coupling: `ended_ttl` drives _both_ the server-side auto-delete _and_ the on-device dismissal date (capped at 4 hours). The minimum is `1`; `0` is rejected with `422`. - **Neither set** β€” the ended card lingers for the full iOS maximum of **4 hours**, then iOS removes it. The database row is kept for 30 days after its last change and then auto-deleted. - **Preemption is exempt** β€” when the server evicts an activity to free a Live Activity slot, the preempted card keeps the `ended_ttl`\-derived linger even if `dismissal_ttl` is `0`. A preempted activity can be restarted, so its card stays glanceable. `dismissal_ttl` is settable on `POST /activities` (re-POSTing an existing slug refreshes it) and on `PATCH /activities/{slug}`, where the usual [merge-patch semantics](#merge-patch) apply. Sending it together with the end transition applies it to that end: End and dismiss immediately ``` curl -X PATCH https://api.pushward.app/activities/backup \ -H "Authorization: Bearer hlk_YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"state": "ended", "dismissal_ttl": 0}' ``` πŸ’‘ Tip Use `dismissal_ttl: 0` for activities whose ended state has no glance value β€” a resolved alert, a finished sync. Keep it unset (or set `ended_ttl`) when the final state is the payoff: a completed download, a score, a delivered package. `ended_ttl` still handles database cleanup either way. ## Alarms (iOS 26+ AlarmKit) Setting `content.alarm: true` schedules a native iOS alarm at `content.end_date` using Apple's **AlarmKit** framework (iOS 26 or later; silently ignored on older versions). The alarm rings like a Clock app alarm β€” breaking through silent mode and Focus β€” in addition to the Live Activity countdown. The user is prompted to authorize alarms the first time one is scheduled; if denied, the alarm is skipped and the Live Activity still updates normally. ### Rules - **Opt-in, persists until cleared.** Once set, the alarm stays armed across partial updates. Clear it with `{"content":{"alarm":null}}` in a `PATCH` body, or by transitioning to `ended`. - **Requires `end_date`.** Sending `alarm: true` without `content.end_date` returns `422 Unprocessable Entity`. - **Updating `end_date` reschedules the alarm.** Send a new PATCH with the new `end_date`; the armed alarm re-derives automatically. - **Past `end_date` is a no-op.** If the push arrives after `end_date` has already elapsed, no alarm is scheduled (but the Live Activity content still updates). - **Designed for the [countdown template](https://pushward.app/docs/live-activities/countdown).** Other templates accept the field if they set `end_date`, but only `countdown` meaningfully uses it. - **What the user sees.** When the alarm fires, iOS presents a full-screen alarm using the activity's `name` as the title with **Dismiss** and **Snooze** buttons. Snooze extends `end_date` by the configured snooze window and sets `snoozed_until` so the Live Activity shows a "Snoozed" banner. The PushWard Live Activity continues to display alongside. - **Configurable snooze window.** Set `content.snooze_seconds` (`60`–`3600`) to control how long Snooze extends the timer; it defaults to `300` (5 min) when omitted. iOS reads this value to size its AlarmKit snooze countdown, so a changed value applies to the next scheduled alarm. ⚠ Warning iOS enforces a small per-app cap on pending AlarmKit alarms. If a user has many concurrent timers with `alarm: true`, iOS rejects further schedules until one fires or is cancelled β€” the Live Activity still updates normally in that case. Start a 25-minute countdown with an alarm on completion ``` # Arm the alarm curl -X PATCH https://api.pushward.app/activities/oven-timer \ -H "Authorization: Bearer hlk_YOUR_TOKEN" \ -H "Content-Type: application/merge-patch+json" \ -d '{ "state": "ongoing", "content": { "template": "countdown", "progress": 0.0, "state": "Baking", "icon": "flame", "duration": "25m", "completion_message": "Done baking!", "accent_color": "orange", "alarm": true } }' ``` ``` # Clear the alarm without touching the rest of content curl -X PATCH https://api.pushward.app/activities/oven-timer \ -H "Authorization: Bearer hlk_YOUR_TOKEN" \ -H "Content-Type: application/merge-patch+json" \ -d '{"content": {"alarm": null}}' ``` AlarmKit complements the existing `sound` field: `sound` plays once when a push arrives, while `alarm` rings continuously at `end_date` until the user dismisses it β€” even if the device was locked and silent the whole time. ## Error Responses All errors follow [RFC 9457 Problem Details](https://www.rfc-editor.org/rfc/rfc9457) and are served with `Content-Type: application/problem+json`. The same shape is used everywhere on the API β€” including auth, rate-limit, subscription-gate, and permission-check responses from middleware. ``` { "type": "about:blank", "title": "Conflict", "status": 409, "detail": "Activity limit reached (max 50).", "instance": "/activities", "code": "activity.limit_exceeded", "retry_after_ms": 3000, "errors": [ { "message": "...", "location": "..." } ] } ``` πŸ’‘ Tip Match on the stable `code` field β€” not the human-readable `detail` string (reworded between releases) and not the HTTP status alone: the same `429` is transient for `rate_limit.exceeded` but sticky until `reset_at` (or an upgrade) for `quota.exceeded`, where an upgrade prompt beats a blind retry loop. `errors` is an array of per-field messages, populated for shape-validation failures. ### Status Codes | Status | Meaning | | --- | --- | | `400` | Malformed JSON or wrong shape. Semantic validation now uses `422`. | | `401` | Missing or invalid token | | `403` | Integration key not allowed for this activity, or insufficient scope | | `404` | Activity not found | | `409` | Conflict β€” e.g. activity limit reached or demo cooldown active. Pairs with a `Retry-After` response header and a `retry_after_ms` extension. | | `422` | Unprocessable: semantic validation failure (e.g. arming `alarm: true` without `end_date`, or `POST /notifications` referencing an unknown `activity_slug`). | | `429` | Two distinct cases β€” distinguish via the `code` field (`rate_limit.exceeded` or `quota.exceeded`). See the [code table below](#known-codes) for per-mode contracts and retry semantics. | ### Known `code` Values | Code | Status | Description | | --- | --- | --- | | `activity.limit_exceeded` | `409` | Per-user activity cap reached on `POST /activities`. | | `demo.conflict` | `409` | Demo flow is on cooldown β€” retry after `retry_after_ms`. | | `subscription.required` | `403` | Endpoint requires an active subscription (granting shares, or redeeming editor-role / pattern share codes). | | `permission.free_recipient_limit_exceeded` | `403` | The activity owner already has the maximum number of free recipients. See [sharing](https://pushward.app/docs/sharing#who-needs-premium) for the policy. | | `permission.editor_requires_paid_recipient` | `403` | Editor access requires the recipient to have an active subscription β€” returned when granting editor rights to (or upgrading) a free recipient. | | `permission.free_share_limit_exceeded` | `403` | The free plan's concurrent shared-activity limit was reached β€” the recipient must leave another shared activity or upgrade to join more. | | `rate_limit.exceeded` | `429` | IP rate limit hit β€” back off using `Retry-After`. | | `quota.exceeded` | `429` | Free-tier monthly quota exhausted. The Problem Details body includes `kind` (`"notifications"`, `"live_activity_updates"`, `"widget_updates"`, or `"emails"`), `used`, `limit`, and `reset_at` (RFC 3339 UTC, the start of next calendar month). The `Retry-After` header gives seconds until `reset_at`. Upgrading the user's plan clears the quota immediately β€” retrying before `reset_at` on the free tier will keep failing. | #### Free-Tier Quota Response Example ``` HTTP/1.1 429 Too Many Requests Content-Type: application/problem+json Retry-After: 1209600 { "type": "about:blank", "title": "Too Many Requests", "status": 429, "detail": "Monthly notification quota exceeded for the free tier.", "instance": "/notifications", "code": "quota.exceeded", "kind": "notifications", "used": 501, "limit": 500, "reset_at": "2026-06-01T00:00:00Z" } ``` --- # MCP Server Drive PushWard from Claude and other AI agents over the Model Context Protocol. The hosted MCP server lets an agent create Live Activities, send notifications, and manage widgets through your integration key. β„Ή Info The [MCP](https://modelcontextprotocol.io) server is the same PushWard REST API behind a tool interface for AI agents. It uses your `hlk_` integration key, so an agent can only do what that key's scopes and capabilities allow. ## Connect from Claude Code Add the server with one command. OAuth runs interactively the first time the agent uses a tool. ``` claude mcp add --transport http pushward https://mcp.pushward.app/mcp ``` ## Connect from other clients Any MCP client that supports remote servers over OAuth β€” Claude Desktop, the Claude apps, and others β€” can connect by adding a remote (custom) connector pointed at the endpoint below: ``` https://mcp.pushward.app/mcp ``` The client discovers the OAuth endpoints automatically and walks you through authorization on first connect. ## Authorize The first time you connect, a browser opens a PushWard consent screen. Paste your `hlk_` integration key once and approve access. The server validates the key, stores it encrypted, and issues short-lived tokens for the session β€” your raw key is never shared with the MCP client. Get your integration key from the iOS app's settings screen. See [Authentication](https://pushward.app/docs/api/authentication) for how keys, scopes, and capability flags work. πŸ’‘ Tip Create a dedicated integration key for the MCP server instead of reusing your default key. Scope it to just the capabilities the agent needs (notifications, widgets, email) so you can revoke it independently. ## What you can do The remote server exposes tools covering the full API surface: - **Live Activities** β€” create, update, end, get, list, and delete activities, plus bulk-end by filter. - **Notifications** β€” send inbox notifications with optional push delivery. - **Widgets** β€” full CRUD for Home Screen widgets. - **Email** β€” send transactional email to verified recipients. - **Health** β€” liveness and readiness checks for the API. - **Test workflows** β€” composite helpers that run a full lifecycle (for example, create an activity, push a few updates, then end it) in one call. - **Reference** β€” built-in tools that return the PushWard docs bundle and integration best practices, so the agent grounds its calls in the real API. β„Ή Info Relay webhook-simulation tools (Grafana, Sonarr, ArgoCD, and the rest) are only available in the local build of the MCP server, not the hosted endpoint. For production webhooks use the [Relay](https://pushward.app/docs/integrations/relay) integration directly. ## Endpoint | Property | Value | | --- | --- | | Endpoint | `https://mcp.pushward.app/mcp` | | Transport | Streamable HTTP | | Authentication | OAuth 2.1, authorized with an `hlk_` integration key | --- # Examples Complete code examples for creating, starting, updating, and ending activities in curl, Python, Go, and JavaScript. β„Ή Info Most examples use the generic template; the tap-actions example below uses `alert`. Replace `hlk_YOUR_KEY` with your integration key. See [Live Activities](https://pushward.app/docs/live-activities) for template-specific fields. ## curl Create activity ``` curl -X POST https://api.pushward.app/activities \ -H "Authorization: Bearer hlk_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"slug": "my-build", "name": "My Build"}' ``` Start activity ``` curl -X PATCH https://api.pushward.app/activities/my-build \ -H "Authorization: Bearer hlk_YOUR_KEY" \ -H "Content-Type: application/merge-patch+json" \ -d '{ "state": "ongoing", "content": { "template": "generic", "progress": 0.0, "state": "Starting...", "icon": "arrow.triangle.branch", "accent_color": "cyan" } }' ``` Update progress ``` curl -X PATCH https://api.pushward.app/activities/my-build \ -H "Authorization: Bearer hlk_YOUR_KEY" \ -H "Content-Type: application/merge-patch+json" \ -d '{ "state": "ongoing", "content": { "template": "generic", "progress": 0.75, "state": "Running tests...", "icon": "arrow.triangle.branch", "accent_color": "cyan" } }' ``` End activity ``` curl -X PATCH https://api.pushward.app/activities/my-build \ -H "Authorization: Bearer hlk_YOUR_KEY" \ -H "Content-Type: application/merge-patch+json" \ -d '{ "state": "ended", "content": { "template": "generic", "progress": 1.0, "state": "Complete", "icon": "checkmark.circle.fill", "accent_color": "green" } }' ``` ## Tap actions: silent-acknowledge an alert This example fires an alert Live Activity whose primary button silently `POST`s to an acknowledgement webhook (no app launch) and whose secondary button opens the Grafana panel in Safari. See [Tap actions](https://pushward.app/docs/api/activities#tap-actions) for the full schema. Fire an alert with tap actions ``` curl -X PATCH https://api.pushward.app/activities/cpu-high \ -H "Authorization: Bearer hlk_YOUR_KEY" \ -H "Content-Type: application/merge-patch+json" \ -d '{ "state": "ongoing", "content": { "template": "alert", "progress": 0.0, "state": "CPU usage is 94.2%", "icon": "exclamationmark.triangle.fill", "subtitle": "Grafana Β· nas-01", "severity": "warning", "accent_color": "red", "url_action": { "url": "https://hooks.example.com/grafana/ack", "method": "POST", "headers": { "Authorization": "Bearer hooks_xxx" }, "body": "{\"alert\":\"cpu-high\",\"acked\":true}", "title": "Acknowledge", "icon": "checkmark.circle" }, "secondary_url_action": { "url": "https://grafana.example.com/d/abc123?viewPanel=1", "foreground": true, "title": "Open panel", "icon": "chart.bar" } } }' ``` πŸ’‘ Tip The **Acknowledge** button fires the HTTP request from the widget process without launching your app β€” perfect for one-tap pager acknowledgements. The **Open panel** button opens Safari (because `foreground: true`) so the user can investigate. ## Python pushward.py ``` import requests BASE_URL = "https://api.pushward.app" TOKEN = "hlk_YOUR_KEY" HEADERS = { "Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json", } # Create activity requests.post(f"{BASE_URL}/activities", headers=HEADERS, json={ "slug": "my-build", "name": "My Build", }) # Start activity requests.patch(f"{BASE_URL}/activities/my-build", headers=HEADERS, json={ "state": "ongoing", "content": { "template": "generic", "progress": 0.0, "state": "Starting...", "icon": "arrow.triangle.branch", "accent_color": "cyan", }, }) # Update progress requests.patch(f"{BASE_URL}/activities/my-build", headers=HEADERS, json={ "state": "ongoing", "content": { "template": "generic", "progress": 0.75, "state": "Running tests...", "icon": "arrow.triangle.branch", "accent_color": "cyan", }, }) # End activity requests.patch(f"{BASE_URL}/activities/my-build", headers=HEADERS, json={ "state": "ended", "content": { "template": "generic", "progress": 1.0, "state": "Complete", "icon": "checkmark.circle.fill", "accent_color": "green", }, }) ``` ## Go main.go ``` package main import ( "bytes" "encoding/json" "fmt" "net/http" ) const ( baseURL = "https://api.pushward.app" token = "hlk_YOUR_KEY" ) func main() { // Create activity post("/activities", map[string]any{ "slug": "my-build", "name": "My Build", }) // Start activity patch("/activities/my-build", map[string]any{ "state": "ongoing", "content": map[string]any{ "template": "generic", "progress": 0.0, "state": "Starting...", "icon": "arrow.triangle.branch", "accent_color": "cyan", }, }) // Update progress patch("/activities/my-build", map[string]any{ "state": "ongoing", "content": map[string]any{ "template": "generic", "progress": 0.75, "state": "Running tests...", "icon": "arrow.triangle.branch", "accent_color": "cyan", }, }) // End activity patch("/activities/my-build", map[string]any{ "state": "ended", "content": map[string]any{ "template": "generic", "progress": 1.0, "state": "Complete", "icon": "checkmark.circle.fill", "accent_color": "green", }, }) } func post(path string, body map[string]any) { doRequest("POST", path, body) } func patch(path string, body map[string]any) { doRequest("PATCH", path, body) } func doRequest(method, path string, body map[string]any) { data, _ := json.Marshal(body) req, _ := http.NewRequest(method, baseURL+path, bytes.NewReader(data)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { fmt.Printf("Error: %v\n", err) return } defer resp.Body.Close() fmt.Printf("%s %s -> %d\n", method, path, resp.StatusCode) } ``` ## JavaScript pushward.js ``` const BASE_URL = "https://api.pushward.app"; const TOKEN = "hlk_YOUR_KEY"; const headers = { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", }; // Create activity await fetch(`${BASE_URL}/activities`, { method: "POST", headers, body: JSON.stringify({ slug: "my-build", name: "My Build", }), }); // Start activity await fetch(`${BASE_URL}/activities/my-build`, { method: "PATCH", headers, body: JSON.stringify({ state: "ongoing", content: { template: "generic", progress: 0.0, state: "Starting...", icon: "arrow.triangle.branch", accent_color: "cyan", }, }), }); // Update progress await fetch(`${BASE_URL}/activities/my-build`, { method: "PATCH", headers, body: JSON.stringify({ state: "ongoing", content: { template: "generic", progress: 0.75, state: "Running tests...", icon: "arrow.triangle.branch", accent_color: "cyan", }, }), }); // End activity await fetch(`${BASE_URL}/activities/my-build`, { method: "PATCH", headers, body: JSON.stringify({ state: "ended", content: { template: "generic", progress: 1.0, state: "Complete", icon: "checkmark.circle.fill", accent_color: "green", }, }), }); ``` --- # Limits Understanding the limits of Live Activities, Apple Push Notification service, and the PushWard API. β„Ή Info Most limits on this page are enforced by **iOS and APNs**, not by PushWard. ## iOS Live Activity Limits ### Concurrent Activities | Limit | Value | Notes | | --- | --- | --- | | Per app | **5** | iOS throws `targetMaximumExceeded` if exceeded | | System-wide (all apps) | Undisclosed | iOS throws `globalMaximumExceeded` if exceeded | PushWard's server enforces the same 5-activity limit proactively by preempting the lowest-priority activity β€” see Priority-Based Eviction below. ### Duration | Phase | Duration | Behavior | | --- | --- | --- | | Active (`ongoing`) | **8 hours** | iOS automatically ends the activity after 8 hours | | Lock Screen after ending | Up to **4 hours** | Remains visible for the user to glance at | | Maximum total on screen | **12 hours** | 8h active + 4h ended visibility | PushWard's background processor detects activities that hit the 8-hour iOS limit and updates their state accordingly on the server. ### Update Throttling ⚠ Warning iOS throttles how often a Live Activity can be updated via push notifications. This is an **iOS-level restriction**, not a PushWard limit. Updates that exceed the budget are silently dropped by the device. | Mode | Update Budget | How to Enable | | --- | --- | --- | | Standard | ~15 updates/hour | Default behavior | | Frequent Updates | Sub-minute delivery | Requires `NSSupportsLiveActivitiesFrequentUpdates` in Info.plist + user opt-in in Settings | PushWard's iOS app has frequent updates enabled. The user can toggle this per-app in **Settings > PushWard > Live Activities > More Frequent Updates**. ## Widget Update Limits ⚠ Warning Apple does **not publish** a per-hour or per-day cap for `apns-push-type: widgets`. APNs delivers widget pushes opportunistically. The "~5 updates per hour" figure that circulates online is community-derived from the on-device timeline-reload budget β€” it is not an enforced limit. WidgetKit budgets timeline reloads on the device, so pinning the same widget in multiple places does not multiply the budget β€” every instance shares one reload pool. The budget is also **adaptive**: widgets the user is actively viewing or has recently interacted with refresh more frequently, while widgets sitting idle in the background may render less often regardless of how many pushes you send. PushWard coalesces widget pushes server-side to one per `push_throttle` window (default **15 seconds**). This guards against runaway integration loops β€” it is _not_ an Apple-imposed cap, and it can be raised or lowered per-widget when you create or update the widget. See `push_throttle` in the [widgets API reference](https://pushward.app/docs/widgets/api). PushWard's iOS app uses Apple's silent background-push channel (`apns-push-type: background`) to refresh in-app state without waking the user. Apple [throttles this channel](https://developer.apple.com/documentation/usernotifications/pushing-background-updates-to-your-app) to no more than **two to three per hour** β€” the app's view of your activities and widgets is brought current opportunistically when the device wakes, or instantly when you open the app. This does not affect Live Activity or widget pushes that drive Lock Screen and Home Screen surfaces. ## PushWard API Limits ### Rate Limiting Rate limits are enforced per IP address. | Endpoint | Limit | | --- | --- | | Authentication (`POST /auth/apple`) | **10** requests/minute | | All API endpoints | **200** requests/minute | | Demo (`POST /demo/run`) | **3** requests/minute | A `429 Too Many Requests` response includes a `Retry-After: 60` header. ### Activity Limits | Limit | Default | | --- | --- | | Max total activities per user | **50** | | Max live (`ongoing`) activities per user | **5** | | Max registered devices per user (Free) | **3** | | Max registered devices per user (Individual / Family / Lifetime) | **10** | | Max integration keys per user | **25** | | Max [widgets](https://pushward.app/docs/widgets) per user | **50** | | Max verified [email recipients](https://pushward.app/docs/email) per account | **50** | Ended activities are automatically deleted 30 days after they last changed, and once deleted they no longer count toward this total. Setting an explicit `ended_ttl` on an activity deletes it sooner. ### Free Tier Quotas Free tier quotas reset on the 1st of each calendar month (UTC): | Quota | Limit (Free) | Limit (Paid) | | --- | --- | --- | | Push notifications | **500** / month | Effectively unlimited (**5,000** / UTCΒ day safety cap) | | Live Activity updates | **250** / month | Unlimited | | [Widget](https://pushward.app/docs/widgets) updates | **50** / month | Unlimited | | Emails | **50** / month | **200** / month | Once a quota is exhausted, further requests return `429 Too Many Requests` with `code: "quota.exceeded"` and a `reset_at` timestamp indicating when the quota resets. See [API errors](https://pushward.app/docs/api/activities#errors) for the full response shape. ### Priority-Based Eviction When a new activity goes `ongoing` and the user already has 5 live activities, the server automatically ends the lowest-priority one. Eviction order: lowest `priority` value first, then oldest `updated_at`. Priority is an integer from **0 to 10** (0 = lowest, 10 = highest). ### Field Length Limits | Field | Max Length | | --- | --- | | `slug` | 128 characters | | `name` | 256 characters | | `content.state` | 256 characters | | `content.subtitle` | 256 characters | | `content.completion_message` | 512 characters | | `content.icon` | 128 characters | | `content.url` | 2,048 characters | | `tap_action.url` / `url_action.url` / `secondary_url_action.url` | 2,048 characters | | Action `body` (silent webhook payload) | 1,024 characters | | Action `headers` (combined keys + values) | 1,024 bytes total | | Action `title` / `icon` | 64 characters | The `slug` must start with an alphanumeric character and may contain letters, numbers, hyphens, and underscores. ### Tap Action Schemes & Methods | Field | Constraint | | --- | --- | | Action `url` scheme | Any scheme except `javascript:`, `data:`, `file:`, `vbscript:` (hard reject). `http(s)` URLs require a host; custom schemes (e.g. `homeassistant://`) are allowed. | | Action `method` | One of `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`. Default `GET`. Only meaningful for silent webhooks. | ### TTL Constraints | Field | Range | | --- | --- | | `ended_ttl` | 1 – 2,592,000 seconds (1s to 30 days) | | `stale_ttl` | 1 – 2,592,000 seconds (1s to 30 days) | | Share code `expires_in` | 60 – 604,800 seconds (1 min to 7 days) | ### Template-Specific Validation | Template | Field | Constraint | | --- | --- | --- | | Generic, Steps | `progress` | 0.0 – 1.0 | | Countdown | `duration` or `end_date` | Required. `duration` accepts seconds (`"30"`) or units (`"1h30m"`, `"5m"`). `end_date` must be a positive Unix timestamp. | | Countdown | `warning_threshold` | β‰₯ 0 seconds | | Steps | `total_steps` | 1 – 64 | | Steps | `current_step` | 0 – `total_steps` | | Steps | `step_rows` values | 1 – 10 per row | | Alert | `severity` | `critical`, `warning`, or `info` | | Gauge | `value`, `min_value`, `max_value` | All three required. `min_value` < `max_value`. `value` must be within range. `progress` is auto-calculated. | | Gauge | `unit` | Max 32 characters | | Timeline | `value` | Required. Must be a labeled object of series (e.g. `{"cpu":42}`); max 10 series, keys max 32 characters. | | Timeline | `scale` | `linear` or `logarithmic` | | Timeline | `decimals` | 0 – 10 | | Timeline | `thresholds` | Max 5 entries. Each: `value` (required), `color`, `label` (max 12 chars) | | Timeline | `history` (server-managed) | Up to 300 points/series stored. Dynamically downsampled via LTTB to fit 4KB APNs payload. | ### Widget Template Validation | Template | Field | Constraint | | --- | --- | --- | | `stat_list` | `stat_rows` | 1 – 6 entries. Each row: `label` ≀ 32 chars, `value` ≀ 32 chars, `unit` ≀ 16 chars. | | `value` | `trend` | One of `up`, `down`, `flat`. Renders an inline arrow on the rectangular (medium) family only. Server accepts the field on `gauge` too but iOS doesn't render it there; `progress`, `status`, and `stat_list` ignore it. | --- # Colors PushWard templates support named colors and hex values for `accent_color`, `background_color`, and `text_color` fields. ## Named Colors red #FF3B30 orange #FF9500 yellow #FFCC00 green #34C759 blue #007AFF purple #AF52DE pink #FF2D55 indigo #5856D6 teal #5AC8FA cyan #32ADE6 mint #00C7BE brown #A2845E ## Usage Pass a named color string directly in the content payload: ``` { "content": { "template": "generic", "progress": 0.5, "state": "Running...", "accent_color": "cyan" } } ``` ## Hex Format For custom colors, use hex RGB or RGBA format: | Format | Example | Description | | --- | --- | --- | | Hex RGB | `#FF5733` or `FF5733` | 6-character hex color | | Hex RGBA | `#FF573380` or `FF573380` | 8-character hex with alpha | ``` { "content": { "template": "generic", "progress": 0.5, "state": "Custom color", "accent_color": "#FF5733", "background_color": "#1A1A2E", "text_color": "#EAEAEA" } } ``` πŸ’‘ Tip The `#` prefix is optional. Both `"#FF5733"` and `"FF5733"` are accepted. ## Color Fields | Field | Description | | --- | --- | | `accent_color` | Primary accent color for progress bars, buttons, and highlights | | `background_color` | Live Activity background color override | | `text_color` | Text color override | --- [![Website](https://img.shields.io/badge/pushward.app-5B4FE5?style=for-the-badge&logo=safari&logoColor=white)](https://pushward.app) [![App Store](https://img.shields.io/badge/App_Store-Download-0D96F6?style=for-the-badge&logo=apple&logoColor=white)](https://apps.apple.com/app/id6759689999) # PushWard Integrations [![CI/CD Backrest](https://github.com/mac-lucky/pushward-integrations/actions/workflows/backrest-ci-cd.yml/badge.svg)](https://github.com/mac-lucky/pushward-integrations/actions/workflows/backrest-ci-cd.yml) [![CI/CD Forgejo](https://github.com/mac-lucky/pushward-integrations/actions/workflows/forgejo-ci-cd.yml/badge.svg)](https://github.com/mac-lucky/pushward-integrations/actions/workflows/forgejo-ci-cd.yml) [![CI/CD GitHub](https://github.com/mac-lucky/pushward-integrations/actions/workflows/github-ci-cd.yml/badge.svg)](https://github.com/mac-lucky/pushward-integrations/actions/workflows/github-ci-cd.yml) [![CI/CD SABnzbd](https://github.com/mac-lucky/pushward-integrations/actions/workflows/sabnzbd-ci-cd.yml/badge.svg)](https://github.com/mac-lucky/pushward-integrations/actions/workflows/sabnzbd-ci-cd.yml) [![CI/CD BambuLab](https://github.com/mac-lucky/pushward-integrations/actions/workflows/bambulab-ci-cd.yml/badge.svg)](https://github.com/mac-lucky/pushward-integrations/actions/workflows/bambulab-ci-cd.yml) [![CI/CD Grafana](https://github.com/mac-lucky/pushward-integrations/actions/workflows/grafana-ci-cd.yml/badge.svg)](https://github.com/mac-lucky/pushward-integrations/actions/workflows/grafana-ci-cd.yml) [![CI/CD Relay](https://github.com/mac-lucky/pushward-integrations/actions/workflows/relay-ci-cd.yml/badge.svg)](https://github.com/mac-lucky/pushward-integrations/actions/workflows/relay-ci-cd.yml) [![golangci-lint](https://github.com/mac-lucky/pushward-integrations/actions/workflows/golangci-lint.yml/badge.svg)](https://github.com/mac-lucky/pushward-integrations/actions/workflows/golangci-lint.yml) Turn events from the services you already run - GitHub Actions, Forgejo Actions, SABnzbd, a Bambu Lab printer, Grafana, and ~19 self-hosted apps behind the relay - into real-time **PushWard Live Activities, widgets, and push notifications** on your iPhone (Dynamic Island + Lock Screen). This is a Go [workspace](https://go.dev/ref/mod#workspaces) of small "bridge" programs, each shipped as its own Docker image. > **New to PushWard?** PushWard is a push-notification platform whose iOS app renders live, updating Live Activities and widgets from any source. Learn more at **[pushward.app](https://pushward.app)** and get the iOS app on the **[App Store](https://apps.apple.com/app/id6759689999)**. ## How it works Each bridge watches or receives events from one external service and calls the public **pushward-server** REST API. The server pushes to Apple (APNs), which delivers a Live Activity, widget update, or notification to the PushWard iOS app. ``` external service -> bridge -> pushward-server REST API -> APNs -> PushWard iOS app (event / webhook) POST/PATCH /activities (Live Activity, POST /widgets widget, or POST /notifications notification) ``` Two shapes of bridge live here: - **Standalone (single-tenant)** - `backrest`, `bambulab`, `forgejo`, `github`, `grafana`, `sabnzbd`. Each container holds **one** PushWard integration key (`hlk_...`) and serves one account. - **Relay (multi-tenant)** - `relay` is a single binary backed by PostgreSQL that fans out to many providers. It carries **no** key in config; instead it reads a per-request `hlk_` key from each webhook's `Authorization` header, so one deployment serves many tenants. ## Bridges Each bridge has its own README with full configuration and per-event behavior. | Bridge | What it pushes | Inbound port | Docker image (GHCR) | |---|---|---|---| | [backrest](./backrest/) | Backrest backup progress with transfer rate and live ETA (poller) | - (outbound only) | `ghcr.io/mac-lucky/pushward-backrest` | | [bambulab](./bambulab/) | Bambu Lab 3D-print progress via local MQTT (TLS) | - (outbound only) | `ghcr.io/mac-lucky/pushward-bambulab` | | [forgejo](./forgejo/) | Forgejo Actions workflow-run CI/CD progress (poller) | - (outbound only) | `ghcr.io/mac-lucky/pushward-forgejo` | | [github](./github/) | GitHub Actions workflow-run CI/CD progress (poller) | - (outbound only) | `ghcr.io/mac-lucky/pushward-github` | | [grafana](./grafana/) | Grafana alert timelines with Prometheus/VictoriaMetrics history + PromQL-polled iOS widgets | 8090 | `ghcr.io/mac-lucky/pushward-grafana` | | [sabnzbd](./sabnzbd/) | SABnzbd download + post-processing progress | 8090 | `ghcr.io/mac-lucky/pushward-sabnzbd` | | [relay](./relay/) | Multi-tenant webhook gateway (20 routes / 16 provider modules) | 8090 (+ 9090 metrics) | `ghcr.io/mac-lucky/pushward-relay` | Images are published to **GitHub Container Registry only** (`ghcr.io/mac-lucky/pushward-`). A Docker Hub name is configured in CI but `push_to_dockerhub` is `false`, so no Docker Hub images are pushed. ### Relay providers The relay registers 20 webhook routes across 16 provider modules (`starr` serves Radarr, Sonarr, and Prowlarr; `gitea` serves Gitea and Forgejo). Every route returns `200 {"status":"ok"}` and is wrapped by the middleware chain (per-IP rate limit -> `hlk_` auth -> per-key rate limit). Most providers create Live Activities; the exceptions are noted. > The relay's `/forgejo` route and the standalone [forgejo](./forgejo/) bridge cover the same service by different > routes: the relay takes Forgejo's terminal `action_run_*` hooks, the bridge polls the API for live per-job > progress. Their slug prefixes differ (`forgejo-` vs `fj-`) so they never contend for one activity - but run both > against one account and each build shows two cards. | Provider | Route | Notes | |---|---|---| | Grafana | `POST /grafana` | Alert firing/resolved lifecycle - sends a grouped **push notification**, not a Live Activity (fire-and-forget) | | ArgoCD | `POST /argocd` | Sync pipeline with deep links and `sync_grace_period` | | Radarr | `POST /radarr` | Movie grab/download/health (HTTP Basic auth) | | Sonarr | `POST /sonarr` | TV episode grab/download/health (HTTP Basic auth) | | Prowlarr | `POST /prowlarr` | Indexer grab, health, application-update - sends **push notifications**, not Live Activities (HTTP Basic auth) | | Jellyfin | `POST /jellyfin` | Playback start/progress/stop, library adds, tasks, auth failures | | Paperless-ngx | `POST /paperless` | Document consumption/processing | | Changedetection.io | `POST /changedetection` | Page-change alert (fire-and-forget) | | Unmanic | `POST /unmanic` | Transcode task completion/failure (Apprise `json://`) | | Bazarr | `POST /bazarr` | Subtitle downloaded/upgraded - sends a **push notification**, not a Live Activity (HTTP Basic auth) | | Proxmox VE | `POST /proxmox` | Backup, replication, fencing, package-update events | | Overseerr / Jellyseerr / Seerr | `POST /overseerr` | Media request lifecycle (pending -> approved -> available), plus issue events as notifications | | Uptime Kuma | `POST /uptimekuma` | Monitor up/down/maintenance changes (alert) | | Gatus | `POST /gatus` | Endpoint health status changes (alert) | | Backrest | `POST /backrest` | Backup/prune/check/forget operations | | Gitea | `POST /gitea` | Gitea Actions workflow-run build progress (steps) | | Forgejo | `POST /forgejo` | Forgejo Actions run result (generic) - for live per-job progress use the [forgejo](./forgejo/) poller bridge instead | | Komodo | `POST /komodo` | Resolvable conditions (server + swarm health) as Live Activities, other alerts as push notifications (HTTP Basic auth via URL userinfo) | | TrueNAS | `POST /truenas/v2/alerts`, `DELETE /truenas/v2/alerts/{id}` | OpsGenie-compatible alert open/clear (GenieKey auth) | Auth styles differ by what each service's webhook UI allows: most providers accept `Authorization: Bearer hlk_...`; Radarr, Sonarr, Prowlarr, Bazarr, and Komodo use HTTP Basic Auth with the `hlk_` key as the **password** (username ignored); TrueNAS uses the OpsGenie `GenieKey` scheme. See the [relay README](./relay/) for per-provider setup snippets. ## Prerequisites - A reachable **PushWard server** - the public base is `https://api.pushward.app`. - A **PushWard integration key** (`hlk_` prefix), created in the PushWard iOS app under Settings -> Integration Keys. Publishing widgets (the `grafana` bridge) needs a key with the `widgets` scope. - The **PushWard iOS app** installed and subscribed to the activity/widget slugs the bridge produces. - The **backend each bridge talks to**: a GitHub token (github), a Forgejo instance URL + API token (forgejo), a Backrest instance (backrest), a SABnzbd instance (sabnzbd), a Bambu Lab printer on the LAN (bambulab), a Prometheus/VictoriaMetrics endpoint (grafana), or PostgreSQL plus the external services' webhooks (relay). ## Installation Run a published image, mounting your config at `/config/config.yml` (the container's default `-config` path). Start from each bridge's `config.example.yml`. ```bash # Standalone bridge (sabnzbd shown; bambulab/grafana/github/forgejo are analogous) docker run -p 8090:8090 \ -v ./config.yml:/config/config.yml:ro \ ghcr.io/mac-lucky/pushward-sabnzbd:latest ``` All settings can also come from `PUSHWARD_*` environment variables (handy for `docker run -e` or Helm), which override the YAML file: ```bash docker run \ -e PUSHWARD_URL=https://api.pushward.app \ -e PUSHWARD_API_KEY=YOUR_API_KEY \ -e PUSHWARD_GITHUB_TOKEN=YOUR_GH_TOKEN \ -e PUSHWARD_GITHUB_OWNER=your-username \ ghcr.io/mac-lucky/pushward-github:latest ``` The relay additionally needs a PostgreSQL DSN and exposes a second (internal-only) metrics port: ```bash docker run -p 8090:8090 -p 9090:9090 \ -v ./config.yml:/config/config.yml:ro \ -e PUSHWARD_URL=https://api.pushward.app \ -e PUSHWARD_DATABASE_DSN='postgres://USER:PASS@HOST:5432/DB?sslmode=disable' \ ghcr.io/mac-lucky/pushward-relay:latest ``` ## Configuration Configuration is layered: a YAML file (optional - a missing file is tolerated and the bridge runs from defaults + env) overlaid by `PUSHWARD_*` environment variables. **Environment variables always win.** Every bridge shares the `pushward.*` block below; bridge-specific keys (`github.*`, `forgejo.*`, `sabnzbd.*`, `bambulab.*`, `metrics.*`, `providers.*`, ...) live in each bridge's README and `config.example.yml`. ### Shared `pushward.*` (standalone bridges) | Env variable | Config key | Description | Required | |---|---|---|---| | `PUSHWARD_URL` | `pushward.url` | PushWard server base URL, e.g. `https://api.pushward.app` | Yes | | `PUSHWARD_API_KEY` | `pushward.api_key` | Integration key (`hlk_` prefix). Standalone bridges only - the relay uses per-request keys instead | Yes (standalone) | | `PUSHWARD_PRIORITY` | `pushward.priority` | Activity priority, validated `0-10` | No (varies per bridge) | | `PUSHWARD_CLEANUP_DELAY` | `pushward.cleanup_delay` | Server `ended_ttl`: how long an ended activity lingers before cleanup | No (`15m`) | | `PUSHWARD_STALE_TIMEOUT` | `pushward.stale_timeout` | Server `stale_ttl`: auto-end an un-updated activity | No (varies) | | `PUSHWARD_END_DELAY` | `pushward.end_delay` | Two-phase end: delay before the final ONGOING frame | No (`5s`) | | `PUSHWARD_END_DISPLAY_TIME` | `pushward.end_display_time` | Two-phase end: how long the final frame shows before ENDED | No (`4s`) | | `PUSHWARD_SERVER_ADDRESS` | `server.address` | HTTP listen address (webhook bridges + relay) | No (`:8090`) | | `PUSHWARD_LOG_LEVEL` | _(env only)_ | `debug`, `info`, `warn` or `error`. Read before the config file, so it works even when config loading is what failed. An unrecognised value warns and stays at `info` rather than refusing to start | No (`info`) | ### Relay-only essentials The relay has **no** `pushward.api_key` (it extracts a `hlk_` key per request). It requires a database and runs a separate internal metrics server. | Env variable | Config key | Description | Required | |---|---|---|---| | `PUSHWARD_URL` | - (or `-pushward-url` flag) | PushWard server base URL | Yes | | `PUSHWARD_DATABASE_DSN` | `database.dsn` | PostgreSQL DSN for the state store. Startup fails if empty | Yes | | `PUSHWARD_DATABASE_PASSWORD_FILE` | `database.password_file` | File holding the DB password (overrides the DSN password; live-rotated via fsnotify) | No | | `PUSHWARD_SERVER_METRICS_ADDRESS` | `server.metrics_address` | Internal Prometheus metrics listener; must differ from `server.address` | No (`:9090`) | | `PUSHWARD_TRUSTED_PROXY_CIDRS` | `trusted_proxy_cidrs` | CIDRs of trusted reverse proxies so `CF-Connecting-IP`/`X-Forwarded-For` is honored for per-IP rate limiting | No | | `PUSHWARD_STARR_MODE` | `providers.starr.mode` | Radarr/Sonarr routing: `activity` (default), `notify`, or `smart` | No | | `PUSHWARD_POSTER_ENABLED` | `poster.enabled` | Poster images on Radarr/Sonarr, Jellyfin and Overseerr cards | No (`true`) | | `PUSHWARD_POSTER_ALLOW_PRIVATE_HOSTS` | `poster.allow_private_hosts` | Let poster fetches reach LAN addresses. **Keep this off on any relay that accepts webhooks from someone else** - the payload carries the URL, so an unguarded fetcher becomes an SSRF probe. Turn it on only when self-hosting and pulling artwork off a LAN media server | No (`false`) | Per-provider knobs (`providers..enabled`, `priority`, `cleanup_delay`, `stale_timeout`, `end_delay`, `end_display_time`) plus the circuit-breaker, poster and OpenTelemetry blocks are documented in the [relay README](./relay/) and [`relay/config.example.yml`](./relay/config.example.yml). All providers default to `enabled: true`. ## Endpoints | Bridge | Endpoints | |---|---| | `backrest` | None - outbound poller, no HTTP server | | `forgejo` | None - outbound poller, no HTTP server | | `github` | None - outbound poller, no HTTP server | | `bambulab` | None - connects **out** to the printer over MQTT (TLS `:8883`) | | `sabnzbd` | `POST /webhook` (optional `X-Webhook-Secret`), `GET /health`, `GET /ready` | | `grafana` | `POST /webhook` (optional `Authorization: Bearer `), `GET /health`, `GET /ready` | | `relay` | 19 `POST` provider routes plus the TrueNAS `DELETE` (above), `GET /health`, `GET /ready`, `GET /openapi.json`, `GET /docs`; `GET /metrics` on the separate `:9090` listener | The relay's OpenAPI 3.1 spec and interactive docs are auto-generated by [huma](https://huma.rocks/) at `/openapi.json` and `/docs`. Webhook receivers with an unset secret/token are **unauthenticated** and log a warning at startup. ## The `shared` library [`shared/`](./shared/) is a library-only module (no `cmd/`, no Dockerfile) that every bridge imports so each one only writes its provider-specific logic. It provides: | Package | Responsibility | |---|---| | `pushward` | Hand-written pushward-server REST client (activities, notifications, widgets) with retry, RFC 9457 problem parsing, and a circuit breaker | | `config` | YAML + `PUSHWARD_*` env loading; tolerates a missing file; validates `url`/`api_key`/`priority` | | `server` | HTTP scaffolding - `NewMux` registers `/health` and `/ready` (method-agnostic; callers use `GET`), plus graceful shutdown | | `ci` | The CI steps ladder shared by the `github` and `forgejo` pollers and the relay's `gitea` provider: job to step-group folding, step colors, prior-run duration weights, live-progress anchors | | `cipoll` | The forge-polling orchestration the `github` and `forgejo` bridges both run on: one activity per repo, the total-steps clamp, redundant-tick suppression, live-progress anchoring, the two-phase end. Each bridge supplies only a `Forge` adapter | | `widgets` | Generic background widget poller publishing numeric values to the widget API (used by the `grafana` bridge today) | | `auth` | Constant-time, fail-closed webhook header checks | | `syncx` | Small concurrency primitives (drop counter, periodic runner, re-armable timer group) | | `text` | Byte/size formatting, rune-safe truncation, slug + URL helpers | | `testutil` | Mock pushward-server that validates requests against the public API contract | The `pushward.Client` retries up to 5 attempts with exponential backoff + jitter (capped 30s) on 5xx/network errors, honors `Retry-After` on `429` (clamped to 2 minutes), and fails fast on other 4xx (except `409` limit-exceeded, surfaced as typed errors). See the [shared README](./shared/) for the full API. ## Project structure This is a Go workspace (`go.work`, Go 1.26.5) with one shared module plus seven independently-versioned bridge modules under `github.com/mac-lucky/pushward-integrations/`: ``` pushward-integrations/ go.work # Go workspace: use ./shared ./github ./forgejo ./sabnzbd ./bambulab ./relay ./grafana ./backrest shared/ # Common library (pushward client, config, server, ci ladder, forge poller, widgets, auth, syncx, text, testutil) backrest/ # Backrest backup poller - standalone bridge bambulab/ # Bambu Lab MQTT client - standalone bridge forgejo/ # Forgejo Actions poller - standalone bridge github/ # GitHub Actions poller - standalone bridge grafana/ # Grafana alert timelines + widgets - standalone bridge sabnzbd/ # SABnzbd webhook + download tracker - standalone bridge relay/ # Multi-tenant webhook gateway (PostgreSQL) - 16 provider modules, 20 routes .github/workflows/ # Per-bridge CI/CD + shared lint + release orchestrator ``` > Note: a few stale compiled binaries (`pushward-github`, `pushward-sabnzbd`, `pushward-bambulab`, `pushward-grafana`, `pushward-mqtt`) are gitignored build artifacts that can show up at the repo root after a local build; they are not committed. They are artifacts, **not** modules - there is no `mqtt/` source module and no `mqtt` entry in `go.work`. MQTT support is the `bambulab` bridge. ## Development Run all commands from the workspace root (where `go.work` lives). ```bash # Build a bridge (pattern: go build .//cmd/pushward-) go build ./relay/cmd/pushward-relay go build ./grafana/cmd/pushward-grafana # Run a standalone bridge with a config file ./pushward-grafana -config grafana/config.example.yml # Run the relay (PUSHWARD_URL + a Postgres DSN are required) PUSHWARD_URL=https://api.pushward.app \ PUSHWARD_DATABASE_DSN='postgres://USER:PASS@HOST:5432/DB?sslmode=disable' \ ./pushward-relay -config relay/config.example.yml # Tests (CI runs Go tests with -race -count=1 -v) go test ./shared/... ./github/... ./forgejo/... ./sabnzbd/... ./bambulab/... ./grafana/... ./backrest/... ./relay/... -race -count=1 -v # Lint, one module at a time (matches CI: golangci-lint v2.11.4) for m in shared github forgejo sabnzbd bambulab grafana backrest relay; do (cd "$m" && golangci-lint run ./...) done ``` > Relay state tests under `relay/internal/state/...` use [testcontainers-go](https://golang.testcontainers.org/) and need a running Docker daemon. > **Lint per module, never from the repo root.** On a `go.work` repo a bare `golangci-lint run` prints `0 issues` and exits 0 while linting nothing at all - the reason is one line above the summary, as `level=error ... "directory prefix . does not contain modules listed in go.work"`. CI is not affected: the reusable lint workflow discovers the `use` entries in `go.work` and runs one job per module. A root-level run is not evidence a change is clean. ### Docker builds The build context is the **repo root** (not the bridge directory) so the Dockerfile can `COPY shared/`. Always pass `-f /Dockerfile .`: ```bash docker build -f relay/Dockerfile -t pushward-relay . docker build -f grafana/Dockerfile -t pushward-grafana . # Pin the Go toolchain (Dockerfile ARG default is 1.26.5) docker build --build-arg GO_VERSION=1.26.5 -f github/Dockerfile -t pushward-github . ``` Each image builds from a `golang:-alpine` builder into an `alpine:3.23` runtime, statically (`CGO_ENABLED=0`), and runs as non-root UID 1000. ## CI/CD & Releases Every per-bridge CI and the release workflow call the reusable `mac-lucky/actions-shared-workflows/.github/workflows/go-cicd-reusable.yml@master`; the lint workflow calls `golangci-lint-reusable.yml@master`. - **Per-bridge CI** (`-ci-cd.yml`) is path-filtered to `/**` and `shared/**`, so a change to `shared/` triggers all seven. - **Lint** (`golangci-lint.yml`) reads the `use` entries out of `go.work` and fans out to one `golangci-lint` v2.11.4 job per module, so every module is linted on its own and one failure does not mask the rest. - **Release** (`release.yml`) fires on per-bridge tags `/v*`, parses the bridge + version, builds that one bridge, and creates a per-bridge GitHub Release with auto-generated, categorized notes (`.github/release.yml`). Bridges are versioned **independently**. ### Image tag channels (GHCR) | Trigger | Tags published | Purpose | |---|---|---| | Pull request | _(none)_ | Tests + analysis only | | Push to `main` | `:main`, `:main-` | Rolling unstable + immutable per-commit pin | | Git tag `/v` | `:X.Y.Z`, `:X.Y`, `:latest` (and `:X` once `X >= 1`) | Stable release | `:latest` moves only on a tagged release - never on a `main` push. ### Cutting a release ```bash # Single bridge (typical bug-fix path) git tag relay/v0.4.1 git push origin relay/v0.4.1 # Coordinated baseline across all bridges for b in backrest bambulab forgejo github grafana relay sabnzbd; do git tag "$b/v0.4.0"; done git push origin backrest/v0.4.0 bambulab/v0.4.0 forgejo/v0.4.0 github/v0.4.0 grafana/v0.4.0 relay/v0.4.0 sabnzbd/v0.4.0 ``` ## Server compatibility Bridges call the public pushward-server REST surface - `POST`/`PATCH /activities`, `POST /notifications`, `POST /widgets` (with snake_case JSON bodies). The contract (paths, request/response keys, auth headers, RFC 9457 problem codes) is owned by pushward-server's `openapi.yaml`; the `shared/pushward` client is hand-written, so keep it in sync when the server contract changes. Bridges target the server API at their `MAJOR.MINOR`; a patch release (`*.*.X`) is a bridge-only fix that needs no coordinated server bump. ## Adding a new bridge 1. **Scaffold the module.** Create `/` with `go.mod` (`module github.com/mac-lucky/pushward-integrations/`, `replace github.com/mac-lucky/pushward-integrations/shared => ../shared`) and `cmd/pushward-/main.go`. 2. **Register it in the workspace.** Add `use ./` to `go.work`. 3. **Reuse `shared`.** Load config with `shared/config`, talk to the server with `shared/pushward`, and (for webhook bridges) serve `/health` + `/ready` via `shared/server.NewMux`. 4. **Add a `config.example.yml`** with the `pushward.*` block and your bridge-specific keys, and a `/README.md`. 5. **Add a `Dockerfile`** that builds from the repo root and `COPY`s `shared/` (copy an existing bridge's Dockerfile). 6. **Wire CI/CD.** Add `.github/workflows/-ci-cd.yml` (path-filtered to `/**` and `shared/**`) and a `` job + tag pattern `/v*` in `release.yml`. To add a **provider to the relay** instead, see the "Adding a New Relay Provider" guide in [`CLAUDE.md`](./CLAUDE.md) and the [relay README](./relay/). ## Troubleshooting Bridges log structured JSON to stdout - `docker logs -f ` (or `kubectl logs`) is your first stop. | Symptom | Likely cause / fix | |---|---| | `401`/`403` from the server | Wrong or scope-limited `hlk_` key. Publishing widgets needs a key with the `widgets` scope | | Nothing appears on iPhone | Wrong `PUSHWARD_URL`, or the iOS app isn't subscribed to the activity/widget slug the bridge produces | | "webhook is unauthenticated" warning | `sabnzbd.webhook_secret` / grafana `webhook_token` is unset - set one and configure the matching header on the sender | | Relay won't start | `PUSHWARD_DATABASE_DSN` missing, or `server.metrics_address` equals `server.address` (they must differ) | | Relay rate-limits real traffic to one bucket | Set `trusted_proxy_cidrs` so the relay trusts your proxy's forwarded-IP headers | | bambulab can't connect | Printer powered off (the bridge retries every 30s), wrong access code, or a cert-fingerprint mismatch after the printer regenerated its cert | | `docker build` fails on `COPY shared/` | Build context must be the repo root: `docker build -f /Dockerfile .` | ## Requirements - Go 1.26.5 (workspace toolchain) and Docker for builds. - A running PushWard server, an `hlk_` integration key, the PushWard iOS app, and the backend each bridge integrates with (see [Prerequisites](#prerequisites)). --- [![Website](https://img.shields.io/badge/pushward.app-5B4FE5?style=for-the-badge&logo=safari&logoColor=white)](https://pushward.app) [![App Store](https://img.shields.io/badge/App_Store-Download-0D96F6?style=for-the-badge&logo=apple&logoColor=white)](https://apps.apple.com/app/id6759689999) [![Docs](https://img.shields.io/badge/Docs-API_Reference-5B4FE5?style=for-the-badge&logo=readthedocs&logoColor=white)](https://pushward.app) [![CI/CD Relay](https://github.com/mac-lucky/pushward-integrations/actions/workflows/relay-ci-cd.yml/badge.svg)](https://github.com/mac-lucky/pushward-integrations/actions/workflows/relay-ci-cd.yml) [![Image](https://img.shields.io/badge/ghcr.io-pushward--relay-2496ED?logo=docker&logoColor=white)](https://github.com/mac-lucky/pushward-integrations/pkgs/container/pushward-relay) # PushWard Relay Self-hostable, multi-tenant webhook gateway that turns webhooks from your homelab and infrastructure tools - Grafana, ArgoCD, the \*arr suite, Proxmox, Jellyfin, and more - into **PushWard Live Activities and push notifications** on iPhone (Dynamic Island + Lock Screen). One binary serves many tenants: each request is authenticated by its own `hlk_` integration key, so there is **no per-service API key in config**. > **New to PushWard?** PushWard delivers real-time Live Activities to your iPhone's Dynamic Island and Lock Screen. Learn more at [pushward.app](https://pushward.app) and download the app from the [App Store](https://apps.apple.com/app/id6759689999). ## Contents - [How it works](#how-it-works) - [Features](#features) - [Prerequisites](#prerequisites) - [Quickstart (Docker)](#quickstart-docker) - [Configuration](#configuration) - [Build & run from source](#build--run-from-source) - [Endpoints](#endpoints) - [Providers](#providers) - [Development](#development) - [CI/CD & Releases](#cicd--releases) - [Server compatibility](#server-compatibility) - [Troubleshooting](#troubleshooting) - [Requirements & License](#requirements--license) ## How it works ``` external service --POST /--> pushward-relay --REST API--> pushward-server --APNs--> iOS (hlk_ key in Authorization) (auth + rate limit) (api.pushward.app) (Live Activity / push) ``` A service POSTs its native webhook to a per-provider route (e.g. `POST /grafana`). The relay extracts the tenant's `hlk_` key from the `Authorization` header, decodes the payload, maps the event to the PushWard activity lifecycle (create / update / two-phase end) or a one-shot push notification, and calls the [pushward-server](https://pushward.app) REST API. The server delivers via APNs to the PushWard iOS app. Per-tenant state (alert grouping, ArgoCD sync tracking, download dedup) is persisted in PostgreSQL with TTL cleanup. ## Features - **Multi-tenant by design** - tenants are identified by their `hlk_` integration key, extracted from every request by shared auth middleware. No per-service key configuration; one relay serves many users. - **20 webhook routes** across **16 configurable provider blocks** (the `starr` block serves Radarr, Sonarr, and Prowlarr; the `gitea` block serves Gitea and Forgejo). See [Providers](#providers). - **Two-phase end lifecycle** - completion events send a final `ONGOING` update (so the result shows on the Dynamic Island), then `ENDED` after a short display delay. Used by ArgoCD, Radarr, Sonarr, Jellyfin, Paperless, Unmanic, Proxmox, Overseerr, Uptime Kuma, Gatus, Backrest, Gitea, Forgejo, Komodo, and TrueNAS. Grafana, Prowlarr, Bazarr, and Changedetection are fire-and-forget. - **Push notifications** - one-shot APNs alerts for events that don't fit a Live Activity (Grafana alerts, Bazarr subtitle downloads, Prowlarr grabs). - **Cross-provider notification threads** - Radarr/Sonarr/Overseerr/Jellyfin notifications about the same movie (TMDB id) or show (TVDB id) collapse into one iOS notification thread. - **PostgreSQL state store** - persistent alert grouping, sync tracking, and download dedup with a background TTL sweep every 30s. - **Per-tenant client pool** - LRU pool of PushWard API clients keyed by `hlk_` hash (up to 1,000 concurrent tenants), wrapped in a shared circuit breaker. - **Dual-layer rate limiting** - per-IP (5 req/s, burst 20) and per-key (1 req/s, burst 10) token buckets. - **Live credential rotation** - optional DB `password_file` watched via fsnotify; the connection pool resets automatically when the file changes. - **Built-in observability** - auto-generated OpenAPI 3.1 spec (`/openapi.json`) + interactive docs (`/docs`), Prometheus `/metrics` on a separate internal listener, and optional OpenTelemetry OTLP/gRPC tracing. - **Graceful shutdown** - flushes pending two-phase ENDED timers and waits for in-flight callbacks on SIGINT/SIGTERM. ## Prerequisites - A running **PushWard server** (`https://api.pushward.app`, or your own deployment) - A **PostgreSQL** database for the relay state store - The **PushWard iOS app** ([App Store](https://apps.apple.com/app/id6759689999)) subscribed to the slugs you push to - One **PushWard integration key** (`hlk_` prefix) per tenant - created in the PushWard app ## Quickstart (Docker) The Docker build context **must be the repo root** so the Dockerfile can `COPY shared/` and `relay/`. ```bash # Build (context = repo root, not the relay/ dir) docker build -f relay/Dockerfile -t pushward-relay . # Run (exposes the webhook server :8090 and the metrics server :9090) docker run -p 8090:8090 -p 9090:9090 \ -v "$(pwd)/config.yml:/config/config.yml:ro" \ -e PUSHWARD_URL=https://api.pushward.app \ -e PUSHWARD_DATABASE_DSN='postgres://user:pass@db:5432/pushward_relay?sslmode=disable' \ pushward-relay ``` ### Docker Compose ```yaml services: pushward-relay: image: ghcr.io/mac-lucky/pushward-relay:latest ports: - "8090:8090" # webhook server - "9090:9090" # internal Prometheus metrics volumes: - ./config.yml:/config/config.yml:ro environment: - PUSHWARD_URL=https://api.pushward.app - PUSHWARD_DATABASE_DSN=postgres://user:pass@db:5432/pushward_relay?sslmode=disable ``` The image `ENTRYPOINT` is `/pushward-relay` with default `CMD ["-config", "/config/config.yml"]`, runs as non-root UID 1000, and exposes ports 8090 and 9090. ## Configuration Settings come from a YAML config file (`-config` flag, default `config.yml`) **or** environment variables. **Environment variables override YAML.** The standardized env prefix is `PUSHWARD_*`. See [`config.example.yml`](./config.example.yml) for the full annotated example. ### Required | Env Variable | Config Key | Description | Required | |---|---|---|---| | `PUSHWARD_URL` | _(none)_ - also `-pushward-url` flag | PushWard server base URL the relay calls to create/update/end activities and send notifications. The `-pushward-url` flag wins over the env var. | Yes | | `PUSHWARD_DATABASE_DSN` | `database.dsn` | PostgreSQL connection string (pgx DSN). Config load fails if empty. | Yes | ### Server & runtime | Env Variable | Config Key | Description | Default | |---|---|---|---| | `PUSHWARD_SERVER_ADDRESS` | `server.address` | Listen address for the main webhook HTTP server. | `:8090` | | `PUSHWARD_LOG_LEVEL` | _(env only)_ | `debug`, `info`, `warn` or `error`. Read before the config file, so it works even when config loading is what failed. An unrecognised value warns and stays at `info`. | `info` | | `PUSHWARD_SERVER_METRICS_ADDRESS` | `server.metrics_address` | Listen address for the internal-only Prometheus metrics server (`GET /metrics`). Must differ from `server.address` or config load fails. Set empty to disable. | `:9090` | | `PUSHWARD_DATABASE_PASSWORD_FILE` | `database.password_file` | Path to a file holding the DB password; overrides the password in the DSN and is watched via fsnotify for live rotation (pool resets on change). | _(empty)_ | | `PUSHWARD_TRUSTED_PROXY_CIDRS` | `trusted_proxy_cidrs` | CIDRs of trusted reverse proxies. Only when `RemoteAddr` falls in one of these are `CF-Connecting-IP` / `X-Real-IP` / `X-Forwarded-For` honored for per-IP rate limiting. Comma-separated as env; a YAML list in the file. | _(empty)_ | | _(none)_ | `circuit_breaker.threshold` | Consecutive outbound-API failures before the breaker opens. Must be `>= 1`. | `5` | | _(none)_ | `circuit_breaker.cooldown` | How long the breaker stays open before allowing a probe. Must be `>= 1s`. | `30s` | ### Telemetry (OpenTelemetry, optional) Tracing is fully disabled when `telemetry.endpoint` is empty. | Env Variable | Config Key | Description | Default | |---|---|---|---| | `PUSHWARD_OTEL_ENDPOINT` | `telemetry.endpoint` | OTLP gRPC endpoint. Empty disables tracing entirely. | _(empty)_ | | `PUSHWARD_OTEL_TLS_CERT_PATH` | `telemetry.tls_cert_path` | Client certificate PEM for mTLS (cert and key both required for mTLS). | _(empty)_ | | `PUSHWARD_OTEL_TLS_KEY_PATH` | `telemetry.tls_key_path` | Client private key PEM for mTLS. | _(empty)_ | | `PUSHWARD_OTEL_SAMPLE_RATE` | `telemetry.sample_rate` | Trace sampling rate `0.0`-`1.0`. A value outside that range falls back to `1.0`; an explicit `0` means sample nothing. | `1.0` | ### Provider toggles All 16 provider blocks default to `enabled: true`. Env toggles exist **only** for `grafana`, `argocd`, `starr`, and `gitea`; every other provider can be disabled via YAML (`enabled: false`). | Env Variable | Config Key | Description | Default | |---|---|---|---| | `PUSHWARD_GRAFANA_ENABLED` | `providers.grafana.enabled` | Enable/disable the Grafana provider. | `true` | | `PUSHWARD_ARGOCD_ENABLED` | `providers.argocd.enabled` | Enable/disable the ArgoCD provider. | `true` | | `PUSHWARD_STARR_ENABLED` | `providers.starr.enabled` | Enable/disable Radarr/Sonarr/Prowlarr. | `true` | | `PUSHWARD_GITEA_ENABLED` | `providers.gitea.enabled` | Enable/disable the Gitea/Forgejo provider. | `true` | | `PUSHWARD_STARR_MODE` | `providers.starr.mode` | Radarr/Sonarr routing: `activity` (default), `notify`, or `smart`. | `activity` | | `PUSHWARD_ARGOCD_URL` | `providers.argocd.url` | ArgoCD UI base URL used to build deep links in activities. | _(empty)_ | | `PUSHWARD_ARGOCD_SYNC_GRACE_PERIOD` | `providers.argocd.sync_grace_period` | Defers activity creation for fast syncs that complete within this window. `PUSHWARD_SYNC_GRACE_PERIOD` is a legacy fallback. | `10s` | ### Per-provider tuning (YAML only) Each provider block accepts these keys (defaults vary per provider - see [`config.example.yml`](./config.example.yml)): | Config Key | Description | Typical default | |---|---|---| | `priority` | PushWard activity priority `0`-`10`. | varies (grafana `10` compiled-in but `5` in `config.example.yml`, uptimekuma/gatus `5`, proxmox `4`, argocd `3`, changedetection/backrest `2`, most `1`) | | `cleanup_delay` | Maps to the activity's ended TTL (how long an ended activity lingers). | `15m` | | `stale_timeout` | State-store stale TTL. Must be `> 0` for any enabled provider - a non-positive TTL writes rows that are never cleaned up (config load fails). | varies (`24h` / `1h` / `30m`) | | `end_delay` | Delay before the final `ONGOING` (phase-1) update; `ENDED` then follows `end_display_time` later. Unused by grafana/changedetection. | `5s` | | `end_display_time` | How long the final completion content shows before `ENDED`. Unused by grafana/changedetection. | `4s` | | `dismissal_delay` | Maps to `dismissal_ttl`: how long an ended card stays on the Lock Screen, which `cleanup_delay` (deletion) otherwise decides. `0` removes it the moment it ends; `null` drops a shipped default and takes the server's. Rejected outside `0`-`4h`. | `2m` on starr/paperless/unmanic/overseerr. Read by those four only; the rest accept the key and ignore it | Provider-specific extras: `argocd.url`, `argocd.sync_grace_period`, `starr.mode`, `jellyfin.progress_debounce` (default `10s`), `jellyfin.pause_timeout` (default `5m`). ## Build & run from source `pushward-relay` lives in a Go workspace (`go.work`) with a shared module. The build path differs depending on where you run it: ```bash # From the pushward-integrations workspace root (uses go.work) go build ./relay/cmd/pushward-relay # From inside the relay/ directory go build -o pushward-relay ./cmd/pushward-relay # Run with a config file (from the workspace root) ./pushward-relay -config relay/config.example.yml # Minimum run with env vars (no config file needed) PUSHWARD_URL=https://api.pushward.app \ PUSHWARD_DATABASE_DSN='postgres://user:pass@localhost:5432/pushward_relay?sslmode=disable' \ ./pushward-relay ``` Flags: `-config` (default `config.yml`) and `-pushward-url` (overrides `PUSHWARD_URL`). ## Endpoints All webhook routes require an `hlk_` key (Bearer, HTTP Basic password, or the OpsGenie `GenieKey` scheme), enforce a 1 MB body limit, and return `200` with `{"status":"ok"}` on success - `401` if the key is missing, `429` when rate-limited. Requests with a missing or `text/plain` `Content-Type` are normalized to `application/json` so misconfigured senders are still accepted. The key itself is only checked when the relay calls PushWard on your behalf, so a webhook that carries a bad key answers `401` on the first request that has something to deliver. A `200` whose `status` is not `"ok"` means the payload arrived but could not be acted on in full; the `detail` field says why. | Method | Path | Description | |---|---|---| | POST | `/grafana` | Grafana alert webhooks | | POST | `/argocd` | ArgoCD sync webhooks | | POST | `/radarr` | Radarr download/library/health webhooks | | POST | `/sonarr` | Sonarr download/library/health webhooks | | POST | `/prowlarr` | Prowlarr indexer grab/health/application-update webhooks | | POST | `/bazarr` | Bazarr subtitle Apprise notifications (push) | | POST | `/jellyfin` | Jellyfin webhook-plugin notifications | | POST | `/paperless` | Paperless-ngx workflow webhooks | | POST | `/changedetection` | Changedetection.io notifications | | POST | `/unmanic` | Unmanic Apprise notifications | | POST | `/proxmox` | Proxmox VE notification webhooks | | POST | `/overseerr` | Overseerr/Jellyseerr request webhooks | | POST | `/uptimekuma` | Uptime Kuma monitor webhooks | | POST | `/gatus` | Gatus health-check alert webhooks | | POST | `/backrest` | Backrest backup/prune/check/forget webhooks | | POST | `/gitea` | Gitea Actions workflow_run/workflow_job webhooks | | POST | `/forgejo` | Forgejo Actions action_run_* webhooks | | POST | `/komodo` | Komodo Custom-alerter webhooks | | POST | `/truenas/v2/alerts` | TrueNAS OpsGenie create-alert calls | | DELETE | `/truenas/v2/alerts/{id}` | TrueNAS OpsGenie close-alert calls | | GET | `/health` | Liveness - returns `ok` | | GET | `/ready` | Readiness - `ready`, or `503` if the DB ping fails | | GET | `/openapi.json` | Auto-generated OpenAPI 3.1 spec | | GET | `/docs` | Interactive API docs | | GET | `/metrics` | Prometheus metrics - served on the **separate** internal listener (`:9090`), not on `:8090` | ## Providers | Service | Route | Auth | Output | Two-phase end | |---|---|---|---|---| | Grafana | `POST /grafana` | Bearer | Push notification | No (fire-and-forget) | | ArgoCD | `POST /argocd` | Bearer | Live Activity (steps) | Yes | | Radarr | `POST /radarr` | Basic | Live Activity (steps) + push (health) | Yes | | Sonarr | `POST /sonarr` | Basic | Live Activity (steps) + push (health) | Yes | | Prowlarr | `POST /prowlarr` | Basic | Push notification | No (fire-and-forget) | | Bazarr | `POST /bazarr` | Basic | Push notification | No (fire-and-forget) | | Jellyfin | `POST /jellyfin` | Bearer | Live Activity + push | Yes | | Paperless-ngx | `POST /paperless` | Bearer | Live Activity | Yes | | Changedetection.io | `POST /changedetection` | Bearer | Live Activity (alert) | No (fire-and-forget) | | Unmanic | `POST /unmanic` | Bearer | Live Activity | Yes | | Proxmox VE | `POST /proxmox` | Bearer | Live Activity | Yes | | Overseerr / Jellyseerr | `POST /overseerr` | Bearer | Live Activity (steps) | Yes | | Uptime Kuma | `POST /uptimekuma` | Bearer | Live Activity (alert) | Yes | | Gatus | `POST /gatus` | Bearer | Live Activity (alert) | Yes | | Backrest | `POST /backrest` | Bearer | Live Activity (steps) + push on failure | Yes | | Gitea | `POST /gitea` | Bearer | Live Activity (steps) | Yes | | Forgejo | `POST /forgejo` | Bearer | Live Activity (generic) | Yes | | Komodo | `POST /komodo` | Basic | Live Activity (alert) + push | Yes | | TrueNAS | `POST /truenas/v2/alerts` Β· `DELETE /truenas/v2/alerts/{id}` | GenieKey | Live Activity (alert) + push | Yes | ### Authentication Every route requires the `hlk_` integration key. The relay accepts it two ways (scheme match is case-insensitive): - **Bearer** (default) - `Authorization: Bearer hlk_...`. Used by Grafana, ArgoCD, Jellyfin, Paperless, Changedetection, Unmanic, Proxmox, Overseerr, Uptime Kuma, Gatus, Backrest, Gitea, Forgejo. - **HTTP Basic** - the `hlk_` key is the password (username ignored), because the sender only offers Basic Auth or a URL with userinfo. Used by **Radarr, Sonarr, Prowlarr, Bazarr, and Komodo**. - **GenieKey** - the OpsGenie scheme (`Authorization: GenieKey hlk_...`). Used by **TrueNAS** (its OpsGenie alert service sends the key this way). ### Query parameters Append query parameters to any webhook URL to override how the relay handles that one request. They work on every route (including the TrueNAS `DELETE`), and an explicit parameter always wins over the provider's computed value and the static config. Leave them off and behavior is byte-for-byte unchanged. | Parameter | Values | Effect | |---|---|---| | `channels` | comma-separated subset of `activity`, `notification` | Restricts delivery to the listed surfaces. `channels=notification` never creates or updates a Live Activity (each event is delivered as a one-shot notification where the provider has one); `channels=activity` drops every push notification the handler would send (new and resolved) but keeps the Live Activity flow. | | `priority` | integer `0`-`10` | Overrides the provider's `priority` config for the activity it creates. | | `level` | `passive`, `active`, `time-sensitive`, `critical` | Overrides the interruption level of every notification the handler sends. | An unknown `channels` value, an out-of-range or non-integer `priority`, or an invalid `level` returns `400` before the handler runs. Example: deliver Komodo as notifications only, at priority 8, with a passive interruption level: ``` https://relay.pushward.app/komodo?channels=notification&priority=8&level=passive ``` Note the asymmetry: Live-Activity-only providers (ArgoCD, Proxmox, Gitea/Forgejo, Jellyfin playback) have no one-shot notification to fall back to, so `channels=notification` suppresses their output entirely; notification-only providers (Grafana, Prowlarr, Bazarr) have no Live Activity, so `channels=activity` suppresses theirs. --- ### Grafana Receives Grafana alert webhooks. Groups alerts by `alertname` into one push notification per group. | | | |---|---| | Route | `POST /grafana` Β· Auth Bearer | | CollapseID | `grafana-` (first 6 bytes = 12 hex chars) | **Events:** `firing` -> active push, `resolved` -> passive push (notification `Level`). Fire-and-forget (no two-phase end). Severity is recorded only in the notification metadata - no color or icon mapping is applied. **Setup:** In Grafana, go to **Alerts & IRM > Alerting > Notification configuration** and open the **Contact points** tab. Add a contact point with integration type **Webhook**. Set the URL to `https://relay.pushward.app/grafana`. Under *Optional settings*, set the Authorization header scheme to `Bearer` and credentials to your `hlk_` key. Adding a `severity` label (`critical`/`warning`/`info`) to alert rules records the severity in the notification metadata. ### ArgoCD Receives ArgoCD sync webhooks via argocd-notifications. Maps sync progress to a 3-step pipeline. | | | |---|---| | Route | `POST /argocd` Β· Template `steps` Β· Auth Bearer | | Slug | `argocd-` | **Events:** `sync-running` -> Step 1/3 Syncing, `sync-succeeded` -> Step 2/3 Rolling out, `deployed` -> Step 3/3 Deployed, `sync-failed` -> Sync Failed, `health-degraded` -> Degraded (transient warning during rollout). **Grace period:** `sync_grace_period` (default `10s`) defers activity creation for fast syncs that complete before the window expires, suppressing no-op notifications. **Setup:** Configure `argocd-notifications-cm` with a webhook service pointing to `POST /argocd`, Go-templated bodies per event, and trigger expressions. Store the `hlk_` key in `argocd-notifications-secret` and reference it as `$KEY_NAME` in the `Authorization: Bearer` header. Use `oncePer: app.status.operationState.startedAt` so every sync fires all events. See the [ArgoCD webhook docs](https://argo-cd.readthedocs.io/en/stable/operator-manual/notifications/services/webhook/). The webhook body needs only: `{"app":"...","event":"...","revision":"...","repo_url":"..."}`. Set `providers.argocd.url` to build deep links. ### Radarr / Sonarr Receives Radarr and Sonarr webhooks. Tracks the download lifecycle from grab to import. | | | |---|---| | Route | `POST /radarr` / `POST /sonarr` Β· Template `steps` (downloads) Β· Auth Basic | | Slug | `radarr-movie-` / `sonarr-series-[-e-]` (falls back to `-` when no TMDB/TVDB id is present, which lets retries of the same media collapse into one activity) | Live Activity events (downloads): | Event | State | Icon | Color | |---|---|---|---| | `Grab` | Grabbed | `arrow.down.circle` | blue | | `ManualInteractionRequired` | Needs attention | `exclamationmark.triangle.fill` | orange | | `Download` | Imported / Upgraded | `checkmark.circle.fill` | green | | `Test` | provider-specific test activity | varies | varies | Push notification events (no Live Activity, no template): | Event | Notification | |---|---| | `Health` | Warning / Critical Β· (health message) | | `HealthRestored` | Resolved Β· (health message) | In the default `activity` mode, `Grab` and `Download` also write a notification record (not pushed) alongside the Live Activity; in `notify`/`smart` mode they are delivered as standalone push notifications instead. Routing is governed by `starr.mode`: `activity` (default, all events -> Live Activity), `notify` (all -> push), or `smart` (handler decides per event). **Setup:** In Radarr/Sonarr, go to **Settings > Connect > + > Webhook**. Set the URL to `https://relay.pushward.app/radarr` (or `/sonarr`). Leave Username as any value, set Password to your `hlk_` key (Basic Auth). Enable triggers: On Grab, On Import, On Health Issue, On Health Restored. Click Test, then Save. ### Prowlarr Receives Prowlarr webhooks. All events are **push notifications**: indexer grabs are grouped into a thread derived from the parsed release base title (Prowlarr payloads carry no TMDB/TVDB id), and health and application-update events are sent as standalone pushes. | | | |---|---| | Route | `POST /prowlarr` Β· Auth Basic | | Thread | `prowlarr-` (grabs) | **Events:** `Grab` -> push notification with indexer, size, and categories Β· `Health` / `HealthRestored` Β· `ApplicationUpdate` Β· `Test`. **Setup:** In Prowlarr, go to **Settings > Connect > + > Webhook**. Set the URL to `https://relay.pushward.app/prowlarr`, leave Username as any value, set Password to your `hlk_` key (Basic Auth). Enable the triggers you want (On Grab, On Health Issue, etc.), then Test and Save. ### Bazarr Receives Bazarr subtitle download notifications via Apprise. Sends a **push notification** (not a Live Activity) with the media title, language, and match score. | | | |---|---| | Route | `POST /bazarr` Β· Auth Basic | | CollapseID | `bazarr-` | | Action | Title | Subtitle | Body | |---|---|---|---| | `downloaded` | Downloaded Β· language | media title | media title Β· Downloaded Β· language Β· score% | | `upgraded` | Upgraded Β· language | media title | media title Β· Upgraded Β· language Β· score% | | `manually downloaded` | Downloaded Β· language | media title | media title Β· Downloaded Β· language Β· score% | **Setup:** In Bazarr, go to **Settings > Notifications**. Add a provider with this URL (the `hlk_` key is the Basic Auth password; the username can be anything): ``` jsons://user:hlk_YOUR_KEY@relay.pushward.app/bazarr ``` Enable subtitle download events, then Test and Save. ### Jellyfin Receives Jellyfin webhook-plugin notifications. Tracks playback, library additions, scheduled tasks, and auth failures. | | | |---|---| | Route | `POST /jellyfin` Β· Template `generic` (playback) Β· Auth Bearer | | Slug (Live Activity) | `jellyfin-` (playback) | | CollapseID (push) | `jellyfin-item-`, `jellyfin-task-`, `jellyfin-auth` | Live Activity events (playback, `generic` template): | Event | State | Icon | Color | |---|---|---|---| | `PlaybackStart` | Playing on (device) | `play.circle.fill` | blue | | `PlaybackProgress` | Playing / Paused on (device) | `play.circle.fill` / `pause.circle.fill` | blue | | `PlaybackStop` | Watched on (device) | `checkmark.circle.fill` | green | Push notification events (no Live Activity, no template): | Event | Notification | |---|---| | `ItemAdded` | Added Β· (media) | | `ScheduledTaskStarted` | Started Β· (task) | | `ScheduledTaskCompleted` | Complete Β· (task) / Failed Β· (task) | | `AuthenticationFailure` | Failed login: (user) from (IP) | `GenericUpdateNotification` triggers a provider-specific test activity. **Debounce:** `PlaybackProgress` updates within `progress_debounce` (default `10s`) are skipped; play/pause state changes bypass the debounce. After `pause_timeout` (default `5m`) of being paused with no progress change, the activity auto-ends. **Setup:** Install the [Webhook plugin](https://github.com/jellyfin/jellyfin-plugin-webhook). Go to **Dashboard > Plugins > Webhook**, add a Generic destination with URL `https://relay.pushward.app/jellyfin`. Under *Add Request Header*, set Key `Authorization` and Value `Bearer hlk_...`. Select notification types: Playback Start/Progress/Stop, Item Added, Task Started/Completed, Authentication Failure. ### Paperless-ngx Receives document consumption webhooks. The JSON body is built from a Jinja2 template in the Paperless Workflows UI. | | | |---|---| | Route | `POST /paperless` Β· Template `generic` Β· Auth Bearer | | Slug | `paperless-` (added/updated), `paperless-` (consumption_started) | | Event | State | Icon | Color | |---|---|---|---| | `added` | Processed | `doc.text.fill` | green | | `updated` | Updated | `doc.text.fill` | green | | `consumption_started` | Processing... | `arrow.triangle.2.circlepath` | blue | **Setup:** In Paperless-ngx, go to **Settings > Workflows** and create a workflow per event type. Action **Webhook**, URL `https://relay.pushward.app/paperless`, encoding JSON, body type Text, header `Authorization: Bearer hlk_...`. Body template for **Document Added** (reuse for Updated with `"event":"updated"`): ``` {"event":"added","doc_id":{{doc_id}},"title":{{doc_title|tojson}},"correspondent":{{correspondent|tojson}},"document_type":{{document_type|tojson}},"doc_url":{{doc_url|tojson}},"filename":{{original_filename|tojson}}} ``` Body template for **Consumption Started** (only `original_filename` is available at this stage): ``` {"event":"consumption_started","filename":{{original_filename|tojson}}} ``` ### Changedetection.io Receives page-change notifications. The JSON body is a custom Jinja2 template in Changedetection's notification settings. | | | |---|---| | Route | `POST /changedetection` Β· Template `alert` Β· Auth Bearer | | Slug | `cd-` | **Events:** single event - page changed. Creates a fire-and-forget alert (ONGOING + immediate ENDED). Icon `eye.fill`, color `#FF9500`, links `diff_url` and `preview_url`. **Setup:** Set the notification URL to: ``` posts://relay.pushward.app/changedetection?+Authorization=Bearer+hlk_YOUR_KEY ``` Set `notification_format` to `custom` with this body: ``` {"url":{{watch_url|tojson}},"title":{{watch_title|tojson}},"tag":{{watch_tag|tojson}},"diff_url":{{diff_url|tojson}},"preview_url":{{preview_url|tojson}},"triggered_text":{{triggered_text|tojson}},"timestamp":{{notification_timestamp|tojson}}} ``` ### Unmanic Receives Apprise `json://` notifications from Unmanic on transcode completion or failure. | | | |---|---| | Route | `POST /unmanic` Β· Template `generic` Β· Auth Bearer | | Slug | `unmanic-` | | Type | State | Icon | Color | |---|---|---|---| | `success` | Complete | `checkmark.circle.fill` | green | | `failure` | Failed | `xmark.circle.fill` | red | | `info` | provider-specific test activity | varies | varies | **Setup:** In Unmanic, go to **Settings > Notifications** and add: ``` jsons://relay.pushward.app/unmanic?+Authorization=Bearer+hlk_YOUR_KEY ``` ### Proxmox VE Receives Proxmox VE notification webhooks for backup, replication, fencing, package-update, and general system (`system-mail`) events. The Datacenter test button is supported too. | | | |---|---| | Route | `POST /proxmox` Β· Template `steps` (backup/replication), `alert` (fencing/package-updates/system-mail) Β· Auth Bearer | | Slug | `proxmox-backup-`, `proxmox-repl-`, `proxmox-fence-`, `proxmox-updates-`, `proxmox-system-` | | Event | State | Icon | Color | |---|---|---|---| | `vzdump` (start) | Backing up... | `externaldrive.fill.badge.timemachine` | blue | | `vzdump` (complete) | Backup Complete | `checkmark.circle.fill` | green | | `vzdump` (failed) | Backup Failed | `xmark.circle.fill` | red | | `replication` (start) | Replicating... | `arrow.triangle.2.circlepath` | blue | | `replication` (complete) | Replication Complete | `checkmark.circle.fill` | green | | `replication` (failed) | Replication Failed | `xmark.circle.fill` | red | | `fencing` | (title) | `exclamationmark.octagon.fill` | red | | `package-updates` | (title) | `arrow.down.circle` | blue | | `system-mail` | (title) | `bell.fill` / `exclamationmark.triangle.fill` | by severity (blue/orange/red) | | test button (empty `type`) | test notification | varies | varies | **Setup:** In Proxmox VE, go to **Datacenter > Notifications** and add a webhook target: - **URL:** `https://relay.pushward.app/proxmox` - **Method:** POST - **Headers:** `Content-Type: application/json` and `Authorization: Bearer {{ secrets.token }}` - **Secrets:** add key `token` with your `hlk_` integration key - **Body:** ``` {"type":"{{ fields.type }}","title":"{{ escape title }}","message":"{{ escape message }}","severity":"{{ severity }}","hostname":"{{ fields.hostname }}"} ``` A target on its own does nothing: Proxmox only calls it when a **matcher** selects a notification and lists that target. Add `PushWard` as a target on a matcher, either the built-in `default-matcher` or a dedicated one. In the UI go to **Datacenter > Notifications**, edit a matcher, and add `PushWard` under the targets to notify (a matcher can list several, so mail and PushWard both fire). Or from the shell: ``` pvesh set /cluster/notifications/matchers/default-matcher --target mail-to-root --target PushWard ``` `--target` replaces the whole list, so pass the existing targets too or you'll drop them. Skip this step and the webhook is never called: the target exists but no event reaches it. The test button on the same screen sends a webhook with an empty `type`, which the relay handles as a self-test so you can confirm delivery without waiting for a real event. ### Overseerr / Jellyseerr / Seerr Receives media request webhooks. Tracks the request lifecycle from pending to available. Overseerr and Jellyseerr merged into [Seerr](https://github.com/seerr-team/seerr) in February 2026; all three speak the same webhook format and use the same route. | | | |---|---| | Route | `POST /overseerr` Β· Template `steps` Β· Auth Bearer | | Slug | `overseerr--` | | Event | State | Step | Color | |---|---|---|---| | `MEDIA_PENDING` | Requested | 1/4 | orange | | `MEDIA_APPROVED` / `MEDIA_AUTO_APPROVED` | Approved | 2/4 | blue | | `MEDIA_AVAILABLE` | Available | 4/4 | green | | `MEDIA_DECLINED` | Declined | - | red | | `MEDIA_FAILED` | Failed | - | red | | `TEST_NOTIFICATION` | test notification | - | varies | These arrive as a push notification only, with no Live Activity: | Event | Notification body | |---|---| | `MEDIA_AUTO_REQUESTED` | Auto-requested | | `ISSUE_CREATED` | Issue reported | | `ISSUE_COMMENT` | New comment | | `ISSUE_RESOLVED` | Issue resolved | | `ISSUE_REOPENED` | Issue reopened | **Setup:** Go to **Settings > Notifications > Webhook**. Set the Webhook URL to `https://relay.pushward.app/overseerr` and the Authorization Header to `Bearer hlk_...`. The stock JSON Payload works as-is; if yours has been edited, reset it or paste this: ```json { "notification_type": "{{notification_type}}", "event": "{{event}}", "subject": "{{subject}}", "message": "{{message}}", "image": "{{image}}", "{{media}}": { "media_type": "{{media_type}}", "tmdbId": "{{media_tmdbid}}", "tvdbId": "{{media_tvdbid}}", "status": "{{media_status}}", "status4k": "{{media_status4k}}" }, "{{request}}": { "request_id": "{{request_id}}", "requestedBy_username": "{{requestedBy_username}}" }, "{{issue}}": { "issue_id": "{{issue_id}}", "issue_type": "{{issue_type}}", "issue_status": "{{issue_status}}", "reportedBy_username": "{{reportedBy_username}}" }, "{{comment}}": { "comment_message": "{{comment_message}}", "commentedBy_username": "{{commentedBy_username}}" }, "{{extra}}": [] } ``` The `{{media}}` block has to list those fields. Whatever object you put under that key is what gets sent verbatim, so an empty `"{{media}}": {}` delivers `"media": {}` and the relay has no TMDB ID to key the Live Activity on. It falls back to a push notification and answers `{"status":"ignored_activity","detail":"..."}` saying which field was missing. Enable: Request Pending, Approved, Available, Declined, Failed, plus any of the issue types you want as notifications. ### Uptime Kuma Receives monitor status webhooks. Maps monitor heartbeat status to alert notifications. | | | |---|---| | Route | `POST /uptimekuma` Β· Template `alert` Β· Auth Bearer | | Slug | `uptime-` | | Status | State | Icon | Color | |---|---|---|---| | `0` (DOWN) | (heartbeat message or "Monitor Down") | `exclamationmark.triangle.fill` | red | | `1` (UP) | Resolved | `checkmark.circle.fill` | green | | `2` (PENDING) | Checking... | `hourglass` | orange | | `3` (MAINTENANCE) | test notification | varies | varies | **Setup:** In Uptime Kuma, go to **Settings > Notifications > Setup Notification**. Type **Webhook**, Post URL `https://relay.pushward.app/uptimekuma`, Request Body JSON. In *Additional Headers*: `{"Authorization": "Bearer hlk_..."}`. Check *Default Enabled* to apply to all monitors. ### Gatus Receives health-check alert webhooks. Maps endpoint TRIGGERED/RESOLVED states to alert notifications. | | | |---|---| | Route | `POST /gatus` Β· Template `alert` Β· Auth Bearer | | Slug | `gatus-` | | Status | State | Icon | Color | |---|---|---|---| | `TRIGGERED` | (error details) | `exclamationmark.triangle.fill` | red | | `RESOLVED` | Resolved | `checkmark.circle.fill` | green | **Setup:** In your `gatus.yaml`, configure `alerting.custom`: ```yaml alerting: custom: url: "https://relay.pushward.app/gatus" method: "POST" headers: Content-Type: "application/json" Authorization: "Bearer hlk_..." body: | { "endpoint_name": "[ENDPOINT_NAME]", "endpoint_group": "[ENDPOINT_GROUP]", "endpoint_url": "[ENDPOINT_URL]", "alert_description": "[ALERT_DESCRIPTION]", "status": "[ALERT_TRIGGERED_OR_RESOLVED]", "result_errors": "[RESULT_ERRORS]" } ``` Reference `type: custom` in your endpoint alerts with `send-on-resolved: true`. ### Backrest Receives backup operation webhooks for snapshot, prune, check, and forget operations. | | | |---|---| | Route | `POST /backrest` Β· Template `steps` (operations), `alert` (errors/skipped) Β· Auth Bearer | | Slug | `backrest-` | | Condition | State | Icon | Color | |---|---|---|---| | `CONDITION_SNAPSHOT_START` | Backing up... | `arrow.triangle.2.circlepath` | blue | | `CONDITION_SNAPSHOT_SUCCESS` | Complete (+ data added, files, duration) | `checkmark.circle.fill` | green | | `CONDITION_SNAPSHOT_WARNING` | Complete (warnings) (+ error) | `exclamationmark.triangle.fill` | orange | | `CONDITION_SNAPSHOT_ERROR` | Failed (+ error) | `xmark.circle.fill` | red | | `CONDITION_SNAPSHOT_END` | resolved from `error`: success or failure frame | | | | `CONDITION_PRUNE_START` | Pruning... | `arrow.triangle.2.circlepath` | blue | | `CONDITION_PRUNE_SUCCESS` | Pruned | `checkmark.circle.fill` | green | | `CONDITION_PRUNE_ERROR` | Prune Failed (+ error) | `xmark.circle.fill` | red | | `CONDITION_CHECK_START` | Checking... | `arrow.triangle.2.circlepath` | blue | | `CONDITION_CHECK_SUCCESS` | Check Passed | `checkmark.circle.fill` | green | | `CONDITION_CHECK_ERROR` | Check Failed (+ error) | `xmark.circle.fill` | red | | `CONDITION_FORGET_START` | Applying retention... | `arrow.triangle.2.circlepath` | blue | | `CONDITION_FORGET_SUCCESS` | Retention applied | `checkmark.circle.fill` | green | | `CONDITION_FORGET_ERROR` | Retention failed (+ error) | `xmark.circle.fill` | red | | `CONDITION_ANY_ERROR` | (error message) | `exclamationmark.triangle.fill` | red | | `CONDITION_SNAPSHOT_SKIPPED` | Snapshot Skipped | `info.circle.fill` | blue | That is every value of Backrest's `Hook.Condition` enum except `CONDITION_UNKNOWN`, Backrest's internal "no condition matched" sentinel, which it never delivers. It is accepted and ignored, so leaving it ticked in the Backrest UI does nothing either way. Prune, check and forget run against a repo rather than a plan. Backrest still fills the hook's `.Plan.Id`, with its `_system_` sentinel, and the relay treats that as no plan: those activities are named "Backup" and their subtitle is just `Backrest Β· `. `CONDITION_SNAPSHOT_END` fires alongside the specific outcome, and Backrest delivers only the first condition a hook subscribes to, with `END` always last in the list. A hook subscribed to both `END` and `SNAPSHOT_SUCCESS`/`_ERROR` therefore never receives `END`; it only arrives for hooks subscribed to `END` on its own, where the outcome is taken from the `error` field. **Notifications:** failures and warnings also send a push notification (time-sensitive, linked to the activity). Routine starts and successes stay Live-Activity-only so a nightly backup does not also push every morning. With `?channels=notification` the activity is suppressed and every outcome notifies instead, successes included. **Setup:** In Backrest, on the Plan or Repo, under *Hooks* click **+ Add Hook** and select **Shoutrrr**. Set *On Error* to "Ignore" so a relay hiccup can never cancel a backup, and tick the conditions per the split below. Set the **Shoutrrr URL** (the `@authorization` param adds the header): ``` generic+https://relay.pushward.app/backrest?@authorization=Bearer+hlk_YOUR_KEY&contenttype=application/json ``` Shoutrrr's `generic://` transport is the one to use. Backrest also lists a plain "Webhook" action, but it has no way to set an `Authorization` header, and as of v1.14.1 it has no backend handler at all, so it never sends anything. **Which conditions go where.** Hooks live on plans and repos, and Backrest runs a repo's hooks for that repo's plans too, so a condition ticked in both places arrives twice. Split them: | Hook on | Conditions | |---|---| | each Plan | `CONDITION_SNAPSHOT_START`, `CONDITION_SNAPSHOT_SUCCESS`, `CONDITION_SNAPSHOT_WARNING`, `CONDITION_SNAPSHOT_ERROR` | | each Repo | `CONDITION_PRUNE_START`, `CONDITION_PRUNE_SUCCESS`, `CONDITION_PRUNE_ERROR`, `CONDITION_CHECK_START`, `CONDITION_CHECK_SUCCESS`, `CONDITION_CHECK_ERROR`, `CONDITION_ANY_ERROR` | Prune and check never reach a plan hook, since Backrest runs them against the repo under its `_system_` plan. That leaves the plan hook as a clean start-to-outcome channel for one backup. Leave `CONDITION_FORGET_*` off both. Unless a repo has a scheduled forget policy, retention runs right after each backup carrying the real plan id, which hashes to that plan's slug, so `CONDITION_FORGET_START` would reset the just-finished backup activity to "Applying retention..." and overwrite its summary. Forget failures still reach you through `CONDITION_ANY_ERROR`, which the relay puts on its own alert slug. Keep `CONDITION_ANY_ERROR` on the repo hook only. It is the one path for failures that fire it alone (index-snapshots, stats, per-plan forget), and on a plan hook it would shadow `CONDITION_SNAPSHOT_ERROR` on a setup-phase failure, leaving "Backing up..." open until the stale timeout. The cost is that a failed backup notifies twice, once as the red activity and once as the alert. `CONDITION_SNAPSHOT_SKIPPED` is left out for the same shadowing reason; give it its own hook if you enable *skip if unchanged*. Set the **Template** (a Go template that renders the JSON body): ``` {"event":"{{ .Event }}","task":{{ .JsonMarshal .Task }},"plan":{{ .JsonMarshal .Plan.Id }},"repo":{{ .JsonMarshal .Repo.Id }},"snapshot_id":"{{ .SnapshotId }}","duration_ms":{{ .Duration.Milliseconds }},"error":{{ .JsonMarshal .Error }}{{ if .SnapshotStats }},"data_added":{{ .SnapshotStats.DataAdded }},"files_new":{{ .SnapshotStats.FilesNew }},"files_changed":{{ .SnapshotStats.FilesChanged }},"files_unmodified":{{ .SnapshotStats.FilesUnmodified }},"total_files_processed":{{ .SnapshotStats.TotalFilesProcessed }},"total_bytes_processed":{{ .SnapshotStats.TotalBytesProcessed }},"total_duration":{{ .SnapshotStats.TotalDuration }}{{ end }}} ``` > **If you set this up before, replace your template with the one above.** The previous version > interpolated the error straight into a JSON string literal instead of through `.JsonMarshal`. > Backrest renders hook templates with `text/template`, which does no escaping, and its errors > routinely quote the command they ran, so any such error produced a body the relay could not > parse. The events you most wanted, the failures, were the only ones that never arrived. Every field except `event` is optional, so an older template keeps working, it just renders less detail. Guard anything from `.SnapshotStats` with `{{ if .SnapshotStats }}` as above: it is nil outside snapshot completion, and an unguarded reference renders `` and breaks the JSON. restic's `dirs_*` and `*_blobs` counters are left out on purpose, they are internal bookkeeping with nothing useful to show on a lock screen. Sending them anyway is harmless, unknown keys are ignored. On a failure the state line shows the error rather than the summary, since only one of them fits. ### Gitea Receives Gitea Actions webhooks and renders a run as a live build-progress Live Activity. Jobs are grouped into steps (matrix jobs fold into one group), and the activity is reused across consecutive runs of the same repo. | | | |---|---| | Route | `POST /gitea` Β· Template `steps` Β· Auth Bearer | | Slug | `gitea-` (one activity per repo) | | Event | Behavior | |---|---| | `workflow_run` requested / in_progress | Creates the activity, seeds "Queued"/"Running" | | `workflow_job` queued / in_progress / completed | Updates per-job step progress | | `workflow_run` completed | Final frame (Success/Failed/Cancelled/Skipped) then two-phase end | A newer run supersedes an older one on the same repo; events for an older run, or jobs arriving after a run completed, are dropped. Runs with more than 10 step groups drop the per-step labels to stay inside the APNs payload budget. **Setup:** In Gitea, go to the repo (or org) **Settings > Webhooks > Add Webhook > Gitea**. Set the URL to `https://relay.pushward.app/gitea`, set **Authorization Header** to `Bearer hlk_...`, and under **Custom Events** enable **Workflow Run** and **Workflow Job**. Requires Gitea 1.24+ for `workflow_job` and 1.25+ for `workflow_run`; 1.26+ is recommended (earlier versions do not emit run-level `in_progress`). ### Forgejo Receives Forgejo Actions webhooks. Forgejo emits only terminal run events (`action_run_success` / `action_run_failure` / `action_run_recover`), so it shows a completion result rather than live per-job progress. | | | |---|---| | Route | `POST /forgejo` Β· Template `generic` Β· Auth Bearer | | Slug | `forgejo-` (one activity per repo) | | Action | State | Icon | Color | |---|---|---|---| | `success` | Succeeded | `checkmark.circle.fill` | green | | `recover` | Recovered | `checkmark.circle.fill` | green | | `failure` | Failed | `xmark.circle.fill` | red | **Setup:** In Forgejo, add a webhook the same way (repo/org **Settings > Webhooks**), URL `https://relay.pushward.app/forgejo`, **Authorization Header** `Bearer hlk_...`, and enable the **Action Run** events. If a future Forgejo release adds `workflow_run`/`workflow_job` webhooks, point it at `/gitea` instead for live progress. The exact minimum Forgejo version shipping the `action_run_*` events is not pinned here; check your Forgejo release notes. ### Komodo Receives Komodo Custom-alerter events. Resolvable server conditions become a Live Activity that resolves when Komodo clears them; every other alert is a one-shot push notification. | | | |---|---| | Route | `POST /komodo` Β· Auth Basic (via URL userinfo) | | Slug | `komodo-` | | Alert kind | Output | |---|---| | Resolvable (`ServerUnreachable`, `ServerCpu`, `ServerMem`, `ServerDisk`, `ServerVersionMismatch`, `SwarmUnhealthy`) | Live Activity (alert) that resolves on clear + active/passive push | | One-shot (container/stack state change, image update, build/procedure/action failed, sync pending, scheduled run, custom, ...) | Push notification (OK -> passive, WARNING -> active, CRITICAL -> time-sensitive) | | `Test` | Test Live Activity | The activity is keyed on the alert condition (target + type), not the alert id, so a resolve collapses onto the same activity as its trigger. The resolve frame always renders "Resolved" (the payload's carried error is stale by then). **Setup:** In Komodo, go to **Settings > Alerters** and add a **Custom** alerter. Store your `hlk_` key as a Komodo Secret and set the alerter URL with the key in userinfo: `https://pushward:[[PUSHWARD_KEY]]@relay.pushward.app/komodo`. Komodo posts via reqwest, which turns the URL userinfo into an HTTP Basic `Authorization` header that the relay reads the key from. ### TrueNAS Emulates the OpsGenie alert service that TrueNAS ships with. TrueNAS opens an alert with `POST /v2/alerts` and clears it with `DELETE /v2/alerts/{alias}`, so each alert becomes a Live Activity that ends when TrueNAS clears it. | | | |---|---| | Route | `POST /truenas/v2/alerts` Β· `DELETE /truenas/v2/alerts/{id}` Β· Auth GenieKey | | Slug | `truenas-` | | Call | Behavior | |---|---| | `POST /v2/alerts` | Creates the activity (alert, warning/orange) + active push | | `DELETE /v2/alerts/{alias}` | Ends the activity (Resolved, green) + passive push; unknown alias is a no-op | **Setup:** In TrueNAS, go to **System Settings > Alert Services > Add**. Set **Type** to **OpsGenie**, **API Key** to your `hlk_` key, and **API URL** to `https://relay.pushward.app/truenas` (no trailing slash). Pick the alert **Level** to forward, then **Send Test Alert** to verify (a test flows as a real create then clear). **Limitations:** TrueNAS's OpsGenie payload carries no hostname (multi-NAS setups cannot tell boxes apart in the activity) and no severity level, so alerts render with a fixed warning style; filter what you forward using the per-service **Level** in TrueNAS. The API URL must have no trailing slash. ## Development Commands match CI (`go-cicd-reusable.yml`, which builds with `go_module_path: ./relay`, `go_test_args: -race -count=1 -v`). ```bash # Build (workspace root) go build ./relay/cmd/pushward-relay # All relay tests go test ./relay/... -v -count=1 # With the race detector (matches CI) go test ./relay/... -race -count=1 -v # Single provider go test ./relay/internal/grafana/... -run TestGrafana -v -count=1 # Lint (matches CI) golangci-lint run # Docker (context is the repo root so the Dockerfile can COPY shared/) docker build -f relay/Dockerfile -t pushward-relay . docker build -f relay/Dockerfile --build-arg GO_VERSION=1.26.5 -t pushward-relay . ``` > DB state tests (`relay/internal/state/...`) use testcontainers-go and require a running Docker daemon. ## CI/CD & Releases Bridges are versioned **independently**. Tag format: `/v` (e.g. `relay/v0.4.1`). Pushing the tag triggers `release.yml`, which builds and publishes images with auto-generated changelog notes (categorized via `.github/release.yml`). Images publish to **GHCR** (`ghcr.io/mac-lucky/pushward-relay`). The image-tag channels: | Trigger | Tags published | Purpose | |---|---|---| | Pull request | _(none)_ | Tests + analysis only | | Push to `main` | _(none)_ | Tests + analysis only (the `:main` dev channel was retired 2026-07-31; Talos follows release tags) | | Git tag `relay/v` | `:X.Y.Z`, `:X.Y`, `:latest` (and `:X` once `X >= 1`) | Stable release | `:latest` moves only on a tagged release - never on a `main` push. ```bash # Single-bridge release git tag relay/v0.4.1 git push origin relay/v0.4.1 ``` ## Server compatibility The relay calls the [pushward-server](https://pushward.app) REST API to create/update/end **activities** (`POST /activities`, `PATCH /activities/{slug}`) and to send notifications - the server then delivers via APNs to the iOS app. The API contract (endpoints, JSON shape, auth headers) is owned by pushward-server's `openapi.yaml`. The relay targets that surface at its `MAJOR.MINOR`; patch releases (`relay/v*.*.X`) are bridge-only fixes that need no coordinated server bump. ## Troubleshooting Logs are structured JSON on stdout (`slog`). View them with `docker logs ` or your platform's log viewer; each request log includes a hashed `tenant` field for correlation (the raw `hlk_` key is never logged). | Symptom | Likely cause | Fix | |---|---|---| | `401` on every webhook | No valid `hlk_` key in the `Authorization` header. | Send `Bearer hlk_...`, or for Radarr/Sonarr/Prowlarr/Bazarr put the key in the Basic Auth **password**. | | `429` responses | Per-IP (5 r/s) or per-key (1 r/s) rate limit. | Slow the sender, or set `trusted_proxy_cidrs` so per-IP limiting uses the real client IP instead of the proxy IP. | | All traffic shares one IP bucket | Running behind a reverse proxy/Cloudflare without `trusted_proxy_cidrs`. A startup warning is logged. | Set `PUSHWARD_TRUSTED_PROXY_CIDRS` to your proxy's CIDRs. | | `config load` fails: `metrics_address must differ from address` | `server.metrics_address` equals `server.address`. | Use different ports (defaults `:8090` / `:9090`), or set `metrics_address` empty to disable metrics. | | `config load` fails: `stale_timeout must be > 0` | A provider has a non-positive `stale_timeout`. | Set a positive duration (a non-positive TTL writes state rows that are never cleaned up). | | `/ready` returns `503` | DB ping check failed. | Verify `PUSHWARD_DATABASE_DSN` / `password_file` and that PostgreSQL is reachable. | | Upstream `401`/`403`/`429` surfaced to the sender | The PushWard server rejected the `hlk_` key or rate-limited. | Check the key is valid and has capacity; the relay forwards these statuses so the source app reports the real cause. | | The provider's `Test` button reports a failure | The self-test could not reach PushWard - most often a refused key. | Read the status: it is the upstream's own. A failed test is no longer answered with `200`, so the button reflects whether the integration actually works. | ## Requirements & License - **Go** `1.26.x` (toolchain `1.26.5`; Docker builds default to `golang:1.26.5-alpine`, final image `alpine:3.23`). - **PostgreSQL** for the state store. - A running **PushWard server** and a per-tenant `hlk_` integration key. Part of the public [`pushward-integrations`](https://github.com/mac-lucky/pushward-integrations) repository - see the repository root for license terms. --- [![Website](https://img.shields.io/badge/pushward.app-5B4FE5?style=for-the-badge&logo=safari&logoColor=white)](https://pushward.app) [![App Store](https://img.shields.io/badge/App_Store-Download-0D96F6?style=for-the-badge&logo=apple&logoColor=white)](https://apps.apple.com/app/id6759689999) # PushWard for Grafana [![CI/CD Grafana](https://github.com/mac-lucky/pushward-integrations/actions/workflows/grafana-ci-cd.yml/badge.svg)](https://github.com/mac-lucky/pushward-integrations/actions/workflows/grafana-ci-cd.yml) [![Image](https://img.shields.io/badge/ghcr.io-pushward--grafana-2496ED?logo=docker&logoColor=white)](https://github.com/mac-lucky/pushward-integrations/pkgs/container/pushward-grafana) Turns Grafana alerts into [PushWard](https://pushward.app) **Live Activity timelines** on iPhone - a live sparkline of the firing metric on the Dynamic Island and Lock Screen, backfilled from Prometheus / VictoriaMetrics history and updated as the metric moves. It can also poll PromQL on a schedule and publish the results as PushWard **iOS Home / Lock Screen widgets**. > **New to PushWard?** Learn what it is at **[pushward.app](https://pushward.app)** and download the app from the **[App Store](https://apps.apple.com/app/id6759689999)**. ## How it works ``` Grafana alert --POST /webhook--> pushward-grafana --query--> Prometheus / VictoriaMetrics | `--REST--> pushward-server --APNs--> iOS Live Activity ``` Grafana POSTs a firing alert to `POST /webhook`. The bridge resolves the series' PromQL (from per-rule annotations, or auto-extracted via the Grafana API), backfills history from your metrics backend, and calls the PushWard server to create a "timeline" activity. A per-alert poller refreshes the sparkline on an interval until the alert resolves, then ends the activity. Widgets follow a separate path: each declared widget is polled from PromQL on its own ticker and published to the server widget API. ## Features - **Timeline sparklines** - the firing series is rendered as a live sparkline with threshold line, unit, and per-series labels. - **History backfill** - on first firing, the bridge queries `history_window` (default `30m`) of points so the sparkline isn't empty (step is `history_window/120`, floored at 15s). - **Multi-series fan-out** - alerts that return multiple results render as a labeled timeline (one line per series); series keys stay stable across firing -> resolved so accumulated history isn't pruned. - **Multi-instance tracking** - an alert firing from multiple instances is grouped by `alertname` and tracked by fingerprint; the activity ends only when *all* fingerprints resolve. - **Auto query extraction** - when a Grafana service-account token is set, PromQL is pulled straight from the alert rule definition, so no per-rule annotations are needed. - **Missed-resolve recovery** - an optional background goroutine polls Grafana's alertmanager API on `alert_check_interval` to close out activities whose `resolved` webhook was dropped. - **Severity styling** - `critical` / `warning` / `info` drive the activity icon and accent color; resolved alerts switch to a green checkmark before dismissal. - **Widgets** - `value` / `progress` / `status` / `gauge` / `stat_list` / `trend` / `countdown` widgets polled from PromQL, with multi-series fan-out via `query_all` + `slug_template`. - **Self-protecting** - webhooks are answered immediately and processed asynchronously (30s budget); in-memory tracking is capped at 500 active alerts and swept for stale entries. ## Prerequisites - A running **PushWard server** (public production base: `https://api.pushward.app`). - A PushWard **integration key** (`hlk_` prefix). Publishing widgets additionally requires the key's **`widgets` scope** (the server returns `403` on the first widget create otherwise). - A **Prometheus or VictoriaMetrics** endpoint reachable from the bridge - queried directly for series history and instant values. - A **Grafana** instance configured to send alert webhooks to this bridge. - *(Optional)* A **Grafana service-account token** (Editor role) to enable PromQL auto-extraction and missed-resolve recovery. - The **PushWard iOS app** installed and subscribed to the relevant activity slugs. ## Quickstart (Docker) The image runs as non-root (UID 1000), listens on `:8090`, and reads `/config/config.yml` by default. ```bash docker run -p 8090:8090 \ -e PUSHWARD_URL=https://api.pushward.app \ -e PUSHWARD_API_KEY=YOUR_API_KEY \ -e PUSHWARD_METRICS_URL=http://prometheus:9090 \ -e PUSHWARD_WEBHOOK_TOKEN=change-me \ ghcr.io/mac-lucky/pushward-grafana:latest ``` Or with Docker Compose and a mounted config file: ```yaml services: pushward-grafana: image: ghcr.io/mac-lucky/pushward-grafana:latest ports: - "8090:8090" volumes: - ./config.yml:/config/config.yml:ro environment: - PUSHWARD_URL=https://api.pushward.app - PUSHWARD_API_KEY=YOUR_API_KEY - PUSHWARD_METRICS_URL=http://prometheus:9090 - PUSHWARD_WEBHOOK_TOKEN=change-me # Optional: PromQL auto-extraction + missed-resolve recovery - PUSHWARD_GRAFANA_URL=http://grafana:3000 - PUSHWARD_GRAFANA_API_TOKEN=YOUR_GRAFANA_TOKEN - PUSHWARD_ALERT_CHECK_INTERVAL=5m ``` A starting `config.yml` lives in [`config.example.yml`](./config.example.yml). ## Grafana webhook setup In Grafana, go to **Alerting -> Notification configuration -> Contact points**, then add a new contact point: - **Integration:** Webhook - **URL:** `http://:8090/webhook` - **HTTP Method:** `POST` - **Authorization Header - Scheme:** `Bearer` - **Authorization Header - Credentials:** the value of `webhook_token` / `PUSHWARD_WEBHOOK_TOKEN` Then route your alert rules (or a notification policy) to that contact point. The same contact point handles both `firing` and `resolved` payloads. ## Configuration Settings come from a YAML config file **or** environment variables. **Env vars override YAML**, and the standardized prefix is `PUSHWARD_*`. Three values are required and the bridge refuses to start without them. | Env Variable | Config Key | Description | Required | |---|---|---|---| | `PUSHWARD_URL` | `pushward.url` | PushWard server base URL (e.g. `https://api.pushward.app`) | **Yes** | | `PUSHWARD_API_KEY` | `pushward.api_key` | Integration key (`hlk_` prefix); needs `widgets` scope to publish widgets | **Yes** | | `PUSHWARD_METRICS_URL` | `metrics.url` | Prometheus / VictoriaMetrics base URL | **Yes** | | `PUSHWARD_METRICS_USERNAME` | `metrics.username` | Basic-auth username for the metrics backend (enables basic auth when set) | No | | `PUSHWARD_METRICS_PASSWORD` | `metrics.password` | Basic-auth password for the metrics backend | No | | `PUSHWARD_METRICS_BEARER_TOKEN` | `metrics.bearer_token` | Bearer token for the metrics backend (enables bearer auth when set) | No | | `PUSHWARD_METRICS_TIMEOUT` | `metrics.timeout` | Per-query timeout for the metrics backend (e.g. `5s`); overrides the built-in 30s default, but only when set to a positive value | No | | `PUSHWARD_GRAFANA_URL` | `grafana.url` | Grafana base URL - set with `api_token` to enable auto-extraction + missed-resolve recovery | No | | `PUSHWARD_GRAFANA_API_TOKEN` | `grafana.api_token` | Grafana service-account token (Editor role) | No | | `PUSHWARD_ALERT_CHECK_INTERVAL` | `grafana.alert_check_interval` | How often to poll Grafana for missed `resolved` webhooks; disabled when `0`/unset (needs `grafana.url` + `api_token`) | No | | `PUSHWARD_WEBHOOK_TOKEN` | `webhook_token` | Shared secret; when set, `/webhook` requires `Authorization: Bearer `. **Recommended** - endpoint is unauthenticated if unset | No | | `PUSHWARD_SERVER_ADDRESS` | `server.address` | HTTP listen address (default `:8090`) | No | | `PUSHWARD_LOG_LEVEL` | _(env only)_ | `debug`, `info`, `warn` or `error` (default `info`). Read before the config file, so it works even when config loading is what failed | No | | `PUSHWARD_PRIORITY` | `pushward.priority` | Activity priority, validated to `0`-`10` (default `5`) | No | | `PUSHWARD_CLEANUP_DELAY` | `pushward.cleanup_delay` | Sent to the server as `ended_ttl`: grace period after resolve before the activity row is deleted and the iOS Lock Screen entry is dismissed (default `15m`; Apple caps Lock Screen dismissal at 4h) | No | | `PUSHWARD_DISMISSAL_DELAY` | `pushward.dismissal_delay` | Server `dismissal_ttl`: how long the ended card stays on the Lock Screen, independent of `cleanup_delay`, which governs deletion. `0` removes it the moment it ends; unset leaves the server default (follows `ended_ttl`, capped at 4h). | No | | `PUSHWARD_STALE_TIMEOUT` | `pushward.stale_timeout` | Time before the in-memory sweeper drops an unresolved alert; also passed to the server (default `24h`; sweeper ticks every `stale_timeout/2`) | No | | `PUSHWARD_HISTORY_WINDOW` | `timeline.history_window` | How far back to backfill series history on initial firing (default `30m`) | No | | `PUSHWARD_POLL_INTERVAL` | `timeline.poll_interval` | How often the per-alert poller refreshes data points (default `30s`) | No | | `PUSHWARD_WIDGETS_JSON` | `widgets` | Full widget list as JSON; **replaces** the YAML `widgets:` list wholesale (Helm-friendly). `interval` is a duration string like `"60s"` | No | > **`cleanup_delay` semantics.** The bridge passes this value to the server as `ended_ttl` at create time. On resolve the server uses it to set the APNs `dismissal-date`, which controls when iOS removes the Live Activity from the Lock Screen. Beyond 4 hours Apple silently caps it. ### Timeline visual settings (YAML only) These tune the sparkline rendering. They have **no environment-variable override** - set them under `timeline:` in the config file. | Config Key | Description | Default | |---|---|---| | `timeline.smoothing` | Curve smoothing on the sparkline | `true` | | `timeline.scale` | Y-axis scale: `linear` or `logarithmic` | `linear` | | `timeline.decimals` | Value precision (decimal places) | `1` | | `timeline.severity_label` | Which alert label drives severity (icon / color) | `severity` | | `timeline.default_severity` | Severity used when the label is absent / unrecognized (`critical` / `warning` / `info`) | `warning` | > **Note:** the shared `ServerConfig` also exposes `server.metrics_address` / `PUSHWARD_SERVER_METRICS_ADDRESS`, but this bridge starts no metrics server, so setting it has no effect. ## Per-rule annotations Add these annotations to a Grafana alert rule to control the resulting timeline. If Grafana auto-extraction is enabled (`grafana.url` + `api_token`), `pushward_query` is optional - the bridge pulls the expression from the rule itself. | Annotation | Purpose | |---|---| | `pushward_query` | PromQL for history backfill and polling. Required when auto-extraction is off. | | `pushward_ref_id` | Which Grafana `values{}` key (ref ID like `B`, `C`) drives the value when an alert reports several. | | `pushward_unit` | Unit rendered next to the value (e.g. `%`, `Β°C`, `ms`). | | `pushward_threshold` | Numeric threshold drawn as a dashed line; leading comparator chars (`> < ! =`) are stripped. | | `pushward_series_label` | Prometheus label used as the series key when fanning out a multi-series alert (e.g. `instance`). | | `summary` | Used as the activity's state text. Falls back to the `alertname` label. | Severity is read from the configured severity label (default `severity`); only `critical` / `warning` / `info` are honored, otherwise `default_severity` applies. ## Widgets Widgets are **independent of alerts**. Each entry in the `widgets:` list (or `PUSHWARD_WIDGETS_JSON`) is polled from PromQL on its own ticker and published to the server widget API. They are created on startup (idempotent - the server upserts on slug) and PATCHed only when the value changes, unless `update_mode: always`. | Template | Query field | Notes | |---|---|---| | `value` | `query` | Scalar number. | | `status` | `query` | Scalar; renders as a status chip. | | `progress` | `query` | Scalar; requires `content.min_value` + `content.max_value`. | | `gauge` | `query` | Scalar; requires `content.min_value` + `content.max_value`. | | `stat_list` | per-row `query` | 1-6 `stat_rows`, each with its own `query` + `value_template`. | | `trend` | `query` | Scalar plus a sparkline built from this bridge's own rolling buffer of the last 48 polls, so it needs no range query and appears after the second poll. No `query_all`: there is one buffer per widget. Under the default `update_mode: on_change` the sparkline only advances when the scalar changes, because the heartbeat re-sends the stored content unchanged; a continuously scrolling sparkline needs `update_mode: always`. | | `countdown` | none | Renders from `content.end_date` on device; published once, so `query`, `query_all` and `stat_rows` are all rejected. | `battery`, `schedule` and `flow` are server widget templates this bridge does not offer: each needs several independent readings in one push, and the poller runs one query per widget. Multi-series fan-out: set `query_all` instead of `query` plus a required `slug_template` to publish one widget per result series. Server-mirrored validation runs at config load: `interval` defaults to `60s` and must be `>= 5s`; `update_mode` is `on_change` (default) or `always`; slug must match `^[a-z0-9_-]{1,128}$`; `stat_list` allows at most 6 rows, with row label <= 32 chars and row unit <= 16 chars. The per-user widget cap on the server is 50. `stale_after` (60-604800 seconds) is how long iOS waits before dimming a widget as out of date. Setting it also arms a heartbeat that re-sends the stored content every `max(30s, stale_after/2)`; the server records an unchanged re-send as a touch rather than a push, so a flat metric costs no notifications and the widget still looks alive. It must be at least three times the poll interval, because the heartbeat rides the poll ticker. `content.subtitle_timer` renders the subtitle as a live timer on any template, and `stat_rows[].timer` does the same for one stat_list row's trailing text. Both take `date` (RFC 3339, required) and `style` (`timer`, the default, or `relative`); the static `subtitle` / `value_template` stays as the fallback for clients that do not render a timer. Any widget can carry tap targets: `content.tap_action` retargets the whole widget, while `content.url_action` and `content.secondary_url_action` draw buttons, which iOS renders on the Home Screen families only. Tap-action URLs are validated at config load against the same rules the server applies - a scheme is required, `javascript:`/`data:`/`file:`/`vbscript:` are rejected, http(s) needs a host, and `method`/`headers`/`body` are only valid on an http(s) URL - so a typo fails at startup rather than on the first push. ```yaml widgets: # Scalar value - slug: "registered-users" name: "Registered Users" template: "value" query: "myapp_users_total" interval: 60s content: icon: "person.3.fill" unit: "users" # Gauge (requires min/max) - slug: "node-cpu" name: "Node CPU" template: "gauge" query: 'avg(100 - rate(node_cpu_seconds_total{mode="idle"}[1m]) * 100)' interval: 30s content: icon: "cpu" unit: "%" min_value: 0 max_value: 100 # Multi-series fan-out: one widget per instance - slug: "node-mem-base" query_all: 'node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes' slug_template: "node-mem-{{.instance}}" name_template: "Memory free on {{.instance}}" template: "progress" content: icon: "memorychip" min_value: 0 max_value: 1 ``` See [`config.example.yml`](./config.example.yml) for the full widget schema (including `stat_list` rows and the per-row `trigger` flag). ## Endpoints | Method | Path | Description | |---|---|---| | `POST` | `/webhook` | Grafana alert webhook (firing + resolved). Responds `200` immediately, processes asynchronously (30s budget); body capped at 1 MiB. Requires `Authorization: Bearer ` when `webhook_token` is set. | | `GET` | `/health` | Liveness probe - returns `200 ok`. | | `GET` | `/ready` | Readiness probe - returns `200 ready` (this bridge registers no readiness checks, so it is always ready). | There is no `/metrics` endpoint - this bridge exposes no Prometheus metrics of its own. ## Development This bridge is a module in the `pushward-integrations` Go workspace (`go.work`). Run these from the **workspace root** so the workspace and the sibling `shared/` module resolve. ```bash # Build go build -o pushward-grafana ./grafana/cmd/pushward-grafana # Run with a config file (the -config flag defaults to config.yml) ./pushward-grafana -config grafana/config.example.yml # Tests (race detector + verbose, matches CI) go test ./grafana/... -race -count=1 -v # Lint (matches CI) golangci-lint run # Docker build - context is the repo root so the Dockerfile can COPY shared/ docker build -f grafana/Dockerfile -t pushward-grafana . # Override the Go toolchain at build time if needed docker build -f grafana/Dockerfile --build-arg GO_VERSION=1.26.5 -t pushward-grafana . ``` > The module targets `go 1.26.5` (`go.mod`), matching the Dockerfile's default `GO_VERSION` build arg (`1.26.5`); override it with `--build-arg GO_VERSION=` only if you need a different toolchain. ## CI/CD & Releases CI runs via [`grafana-ci-cd.yml`](../.github/workflows/grafana-ci-cd.yml) on PRs and pushes to `main` (path-filtered to `grafana/**` and `shared/**`). Images publish to **GHCR only** - `ghcr.io/mac-lucky/pushward-grafana` (a Docker Hub name is configured but `push_to_dockerhub` is `false`). | Trigger | GHCR tags | Purpose | |---|---|---| | Pull request | _(none)_ | Tests + analysis only | | Push to `main` | _(none)_ | Tests + analysis only | | Git tag `grafana/v` | `:X.Y.Z`, `:X.Y`, `:latest` (and `:X` once `X >= 1`) | Stable release | `:latest` only moves on a tagged release - never on a `main` push. Bridges are versioned independently; tag format is `grafana/v`: ```bash git tag grafana/v0.4.1 git push origin grafana/v0.4.1 ``` The tag triggers [`release.yml`](../.github/workflows/release.yml), which runs the release pipeline for this bridge and produces a GitHub Release with an auto-generated changelog (categorized via `.github/release.yml`). ## Server compatibility This bridge calls the [pushward-server](https://pushward.app) REST API to create / update / end **activities** (`POST /activities`, `PATCH /activities/{slug}`) and to publish **widgets** (`POST /widgets`, `PATCH /widgets/{slug}`, `DELETE /widgets/{slug}`). The wire contract - routes, JSON keys and casing, and auth headers - is owned by pushward-server's `openapi.yaml`. Bridges target the server API surface at their `MAJOR.MINOR`; a patch release is a bridge-only fix. Released iOS clients can't be hot-fixed, so the server contract is the binding compatibility surface. ## Troubleshooting Logs are structured JSON on stdout (`docker logs pushward-grafana`). The process logs `webhook bearer auth enabled` or a warning that the endpoint is unauthenticated, and `grafana auto-extract enabled` when the Grafana API is wired up - check those lines first. | Symptom | Likely cause / fix | |---|---| | Process exits immediately on start | A required value is missing: `pushward.url` / `PUSHWARD_URL`, `pushward.api_key` / `PUSHWARD_API_KEY`, or `metrics.url` / `PUSHWARD_METRICS_URL`. | | `403` on first widget create | The integration key lacks the `widgets` scope. Use a key with widget capability. | | Empty sparkline / no history | `pushward_query` is missing and auto-extraction is off (set `grafana.url` + `grafana.api_token`), or the metrics backend can't run the PromQL. Verify `metrics.url` and any `metrics.username` / `bearer_token` auth. | | Webhook accepted (`200`) but no activity appears | Processing is async - check logs for the firing alert. Confirm the alert has a non-empty fingerprint and that the activity slug is subscribed in the iOS app. | | Activity never ends | The `resolved` webhook was dropped. Enable missed-resolve recovery with `grafana.url` + `grafana.api_token` + `alert_check_interval`; otherwise the stale sweeper closes it out after `stale_timeout`. | | `401`/`403` from Grafana to the bridge, or vice-versa | The `webhook_token` in the contact point's `Authorization: Bearer` header must match `PUSHWARD_WEBHOOK_TOKEN`; the Grafana service-account token must have Editor role. | | Firing alerts silently dropped | The in-memory active-alert cap (500) was hit; a rate-limited warning is logged. Reduce alert volume or tune grouping. | ## Requirements & License - Go 1.26.x toolchain (for building from source); Docker for the container image. - A running PushWard server, an `hlk_` integration key, a Prometheus/VictoriaMetrics endpoint, and the PushWard iOS app. Part of the public [pushward-integrations](https://github.com/mac-lucky/pushward-integrations) repository. --- [![Website](https://img.shields.io/badge/pushward.app-5B4FE5?style=for-the-badge&logo=safari&logoColor=white)](https://pushward.app) [![App Store](https://img.shields.io/badge/App_Store-Download-0D96F6?style=for-the-badge&logo=apple&logoColor=white)](https://apps.apple.com/app/id6759689999) [![CI/CD GitHub](https://github.com/mac-lucky/pushward-integrations/actions/workflows/github-ci-cd.yml/badge.svg)](https://github.com/mac-lucky/pushward-integrations/actions/workflows/github-ci-cd.yml) [![Image](https://img.shields.io/badge/ghcr.io-pushward--github-2496ED?logo=docker&logoColor=white)](https://github.com/mac-lucky/pushward-integrations/pkgs/container/pushward-github) # PushWard for GitHub Actions Polls the GitHub Actions API for in-progress workflow runs and pushes their live progress to [PushWard](https://pushward.app) as iOS Live Activities on the Dynamic Island and Lock Screen. `pushward-github` is a standalone, outbound-only poller: it reads the GitHub REST API and writes to the PushWard activities API. It runs no HTTP server of its own and serves a single PushWard account (one `hlk_` key per instance). > **New to PushWard?** Learn more at **[pushward.app](https://pushward.app)** and get the iOS app on the **[App Store](https://apps.apple.com/app/id6759689999)**. ## How it works ``` GitHub Actions API --poll--> pushward-github --POST/PATCH /activities--> PushWard server --APNs--> iOS Live Activity ``` 1. **Discover** - if `github.owner` is set, every non-archived, non-disabled repo under that owner is listed and refreshed every 5 minutes; any `github.repos` you list are merged in and de-duplicated. 2. **Detect** - each repo is polled (default every 60s, `polling.idle_interval`) for in-progress runs via `GET /repos/{owner}/{repo}/actions/runs?status=in_progress`; the most recently created run is tracked. Repos with no workflows are skipped, and the probe is a conditional request, so an unchanged answer costs no rate limit at all. 3. **Create** - a Live Activity is created (`POST /activities`) and seeded with the `steps` template, triggering a push-to-start Live Activity on subscribed iPhones. 4. **Update** - a run already in flight is advanced on its own faster interval (default 15s, `polling.interval`): the bridge fetches the run's jobs, groups matrix/reusable-workflow jobs by base name into steps, and sends `PATCH /activities/{slug}` only when something changed (or a heartbeat is due). 5. **End** - on completion it runs a two-phase end: a final `ONGOING` frame (green for success, red for failure/cancel) so the result lands on the Dynamic Island, then `ENDED` to dismiss the activity. ## Features - **Repo auto-discovery** - set `github.owner` and all of that account's non-archived, non-disabled repos are monitored automatically, refreshed every 5 minutes. - **Stable step total** - GitHub creates jobs lazily (behind `needs:`/`if:`), so a fresh scan can't know the final count. The `X/N` denominator is seeded from a prior finished run of the same workflow + branch (last success preferred), giving a steady total from the first frame; falls back to a live scan when there is no prior run. - **Matrix & reusable-workflow grouping** - parallel matrix jobs (`Build (ubuntu, node-16)`) collapse into one step with per-shard `step_rows`; reusable caller prefixes (`ci-cd / Build` -> `Build`) are stripped for clean labels. - **Duration-sized, color-coded pills (opt-in)** - both off by default, and independent of each other. `PUSHWARD_GITHUB_STEP_WEIGHTS=true` sizes each pill (`step_weights`) by how long that group took in the previous run (the longest job, for a matrix group), so a long build reads wider than a quick lint; widths stay equal when there is no prior run. `PUSHWARD_GITHUB_STEP_COLORS=true` tints pills (`step_colors`) by job type (tests, lint, build, docker, deploy, security). With both off, the bridge sends the plain `step_rows` / `step_labels` layout. - **Self-filling step with a live ETA (on by default)** - the running step's pill fills on the phone between polls and its ETA counts down there, so a ten-minute build reads as motion instead of a frozen bar. The window runs from when the job actually started to that plus however long the same job took in the previous run, so a poll landing mid-step picks the bar up where it already is. A step with no measurement of its own (a job added since the last run) counts down toward the average of the run instead, the same estimate its pill is drawn at; only a first run on a branch, with nothing measured anywhere, keeps the static bar and the `X/N` counter. A step that outlasts its estimate stops at full rather than racing ahead. Set `PUSHWARD_GITHUB_LIVE_PROGRESS=false` to send the plain static bar instead. - **Monotonic progress** - the total step count only ever clamps upward across polls; it never decreases mid-run. - **Two-phase end** - a final result frame is held for `end_display_time` before the activity is dismissed; the last frame forces `N/N` so an over-counted seed self-heals to a full bar. - **Accent colors & deep links** - green while running, red on failure; each update carries the workflow-run URL and a secondary link to the repository. - **Eviction guards** - a tracked run is evicted if its jobs endpoint goes silent for longer than `stale_timeout + 30s`, and any run wedged `in_progress` is reclaimed after an absolute 12-hour lifetime so it never blocks new-run detection. - **Two polling tiers** - detecting a new run costs a request per watched repo, while advancing one already in flight costs a request per *run*. They are separate knobs because of that: `idle_interval` buys detection latency and sets the idle request rate, `interval` is what someone watching the card sees and stays cheap however many repos there are. - **Conditional requests and a workflow filter** - the detection probe sends `If-None-Match`, and GitHub does not charge a `304` against the primary rate limit, so polling an idle repo is effectively free. Repos with no workflows at all (or with Actions disabled) are written off for half an hour at a time rather than probed every pass. - **GitHub rate-limit handling** - detection paces itself to fit the allowance left in the current window, dropping repo discovery first and never the runs it is already tracking, so a card on your lock screen keeps moving even when the budget is thin. Rate-limited responses (`429`, or `403` with rate-limit headers) are retried honoring `Retry-After` / `X-RateLimit-Reset`; only a rate-limit response carrying no usable timing at all backs off exponentially from a minute, which is what GitHub's own guidance prescribes for a secondary limit. Other 4xx fail fast. - **Server-managed cleanup** - `cleanup_delay` and `stale_timeout` are passed to the server as `ended_ttl` / `stale_ttl`, so finished and stalled activities are auto-deleted server-side. ## Prerequisites - A running [PushWard](https://pushward.app) server (the public API is `https://api.pushward.app`). - A PushWard integration key (`hlk_` prefix) with the activity-manage capability. - A GitHub personal access token with `actions:read` (read workflow runs and jobs); add `repo` to discover and monitor private repositories. - The PushWard iOS app installed and subscribed to the repos you want to see. ## Installation The published image is on GHCR only (Docker Hub publishing is disabled in CI): ```bash docker pull ghcr.io/mac-lucky/pushward-github:latest ``` ### Docker run ```bash docker run --rm \ -e PUSHWARD_GITHUB_TOKEN=YOUR_GH_TOKEN \ -e PUSHWARD_GITHUB_OWNER=your-github-username \ -e PUSHWARD_URL=https://api.pushward.app \ -e PUSHWARD_API_KEY=YOUR_API_KEY \ ghcr.io/mac-lucky/pushward-github:latest ``` The official image pre-sets `PUSHWARD_URL=https://api.pushward.app`, so you can omit it when targeting the public server. ### Docker Compose ```yaml services: pushward-github: image: ghcr.io/mac-lucky/pushward-github:latest restart: unless-stopped # Either mount a config file... volumes: - ./config.yml:/config/config.yml:ro # ...or configure entirely via env vars (these override the YAML): environment: - PUSHWARD_GITHUB_TOKEN=YOUR_GH_TOKEN - PUSHWARD_GITHUB_OWNER=your-github-username - PUSHWARD_URL=https://api.pushward.app - PUSHWARD_API_KEY=YOUR_API_KEY ``` The container runs as non-root (UID 1000); its entrypoint reads `-config /config/config.yml` by default. ## Configuration Settings come from a YAML file and/or environment variables. **Environment variables (prefix `PUSHWARD_*`) override the YAML.** At least one of `github.owner` or `github.repos` is required; both can be combined. ```yaml github: token: "" # or PUSHWARD_GITHUB_TOKEN owner: "your-github-username" # or PUSHWARD_GITHUB_OWNER - auto-discovers all repos repos: # or PUSHWARD_GITHUB_REPOS (comma-separated) - optional when owner is set # - "other-org/some-repo" # add repos outside owner if needed pushward: url: "" # or PUSHWARD_URL (e.g. https://api.pushward.app) api_key: "" # or PUSHWARD_API_KEY (hlk_ integration key) # priority: 1 # PUSHWARD_PRIORITY (0-10) # cleanup_delay: 15m # PUSHWARD_CLEANUP_DELAY -> server ended_ttl # stale_timeout: 30m # PUSHWARD_STALE_TIMEOUT -> server stale_ttl # end_delay: 5s # PUSHWARD_END_DELAY # end_display_time: 4s # PUSHWARD_END_DISPLAY_TIME polling: idle_interval: 60s # or PUSHWARD_POLL_IDLE render: # step_colors: false # PUSHWARD_GITHUB_STEP_COLORS -> tint pills by job type # step_weights: false # PUSHWARD_GITHUB_STEP_WEIGHTS -> size pills by prior-run duration # live_progress: true # PUSHWARD_GITHUB_LIVE_PROGRESS -> fill the running step, count its ETA down ``` | Env Variable | Config Key | Description | Required | Default | |---|---|---|---|---| | `PUSHWARD_GITHUB_TOKEN` | `github.token` | GitHub PAT (`actions:read`; add `repo` for private repos). Sent as `Authorization: Bearer`. | Yes | - | | `PUSHWARD_GITHUB_OWNER` | `github.owner` | GitHub user/org login. When set, all non-archived, non-disabled repos are discovered and refreshed every 5 min. | One of owner/repos | - | | `PUSHWARD_GITHUB_REPOS` | `github.repos` | Explicit `owner/repo` list (env: comma-separated), merged with discovered repos. | One of owner/repos | - | | `PUSHWARD_URL` | `pushward.url` | PushWard server base URL. Required in config, but the official image pre-sets `https://api.pushward.app`. | Yes[1] | - (image: `https://api.pushward.app`) | | `PUSHWARD_API_KEY` | `pushward.api_key` | PushWard integration key (`hlk_` prefix). | Yes | - | | `PUSHWARD_PRIORITY` | `pushward.priority` | Activity priority sent to the server (validated 0-10). | No | `1` | | `PUSHWARD_CLEANUP_DELAY` | `pushward.cleanup_delay` | Passed as `ended_ttl`: how long the server keeps an activity after it ends. | No | `15m` | | `PUSHWARD_DISMISSAL_DELAY` | `pushward.dismissal_delay` | Server `dismissal_ttl`: how long the ended card stays on the Lock Screen, independent of `cleanup_delay`, which governs deletion. `0` removes it the moment it ends; unset leaves the server default (follows `ended_ttl`, capped at 4h). | No | unset | | `PUSHWARD_STALE_TIMEOUT` | `pushward.stale_timeout` | Passed as `stale_ttl`; also drives the heartbeat interval (`/2`) and the stale-run eviction guard (`+30s`). | No | `30m` | | `PUSHWARD_END_DELAY` | `pushward.end_delay` | Wait after run completion before the final `ONGOING` frame (two-phase end, phase 1). | No | `5s` | | `PUSHWARD_END_DISPLAY_TIME` | `pushward.end_display_time` | How long the final frame shows before `ENDED` dismisses the activity (phase 2). | No | `4s` | | `PUSHWARD_POLL_IDLE` | `polling.idle_interval` | How often every watched repo is checked for a run that has just started. One request per repo per pass, so this sets the idle request rate - see [Request budget](#request-budget). | No | `60s` | | `PUSHWARD_POLL_INTERVAL` | `polling.interval` | How often a run already in flight is advanced. One request per running run. Must not exceed `idle_interval`. | No | smaller of `idle_interval` and `15s` | | `PUSHWARD_GITHUB_STEP_COLORS` | `render.step_colors` | Send `step_colors` so pills are tinted by job type. Off sends no colors and pills take the accent color. | No | `false` | | `PUSHWARD_GITHUB_STEP_WEIGHTS` | `render.step_weights` | Send `step_weights` so pills are sized by the previous run's per-group duration. Off sends no weights and pills render equal-width. | No | `false` | | `PUSHWARD_GITHUB_LIVE_PROGRESS` | `render.live_progress` | Send `live_progress` with a `start_date`/`end_date` window so the running step's pill fills and its ETA counts down on the phone between polls. Off sends none of the three and the pill only moves on a push. | No | `true` | | `PUSHWARD_LOG_LEVEL` | _(env only)_ | `debug`, `info`, `warn` or `error`. Read before the config file, so it works even when config loading is what failed. | No | `info` | Turning `live_progress` off stops the bridge sending the field at all, which keeps the payload identical to one from before the feature existed. Updates are merge-patches, so an activity that is mid-animation when you switch it off keeps animating until it ends or `stale_timeout` reaps it; the next run starts clean. When a step's pill is not animating, run with `PUSHWARD_LOG_LEVEL=debug`. The bridge then logs `live progress not anchored` with the reason: no step running, no measured duration for this step group (the usual one - no prior finished run of that workflow on that branch), the forge has not stamped a start, or the estimate is already spent. A group that finishes in under five seconds never animates by design. [1] Required at the config layer; effectively optional when running the official image, which sets `PUSHWARD_URL` to the public API. > Note: the comment in `config.example.yml` lists `stale_timeout: 60m`, but the in-code default is `30m` (shown above). Set it explicitly if you depend on a specific value. ### Request budget An authenticated personal access token gets 5,000 requests an hour. Detection spends one per watched repo per pass, so the shape of the bill is: ``` repos x 3600 / idle_interval detection + 3600 / interval per run in flight + 12 repo discovery, every 5 minutes ``` At the 60s default that is 44 repos x 60 = 2,640 an hour, or 53% of the budget. **The arithmetic scales with your repo count, and it is easy to walk past the ceiling without noticing** - the same 44 repos at a 30s interval is 5,280 an hour, over the limit. The bridge logs the figure it computes at startup and warns when it exceeds the budget, so check the first few lines of the log after changing either interval or adding repos. That figure is a sanity check on the configuration, not an accounting of the window: it counts only what the poll loop itself issues, and three things move the real number below it. - The detection probe is conditional, and GitHub does not charge a `304` against the primary rate limit. Once each repo has been seen, an idle pass costs close to nothing. - Repos with no workflows are not polled at all. On a typical account that is a large share of everything discovery finds. The presence check that establishes this is conditional too, so a skipped repo costs one request ever rather than one per re-check. - If the allowance does run short anyway, detection stretches itself to fit what is left of the window instead of spending it. Discovery stops first, then detection. Runs already being tracked are never dropped - the reserve held back for them is sized from how many are in flight and how long until the window refills - so cards on screen keep updating. Raising `idle_interval` is the lever if you want more headroom; it only delays noticing a *new* run, and does not affect how smoothly a running one updates. ## How it maps to a Live Activity Each tracked run becomes one PushWard activity: - **Slug** - `gh-<8 hex chars>`, derived from `SHA-256(owner/repo)` (e.g. `gh-1a2b3c4d`), stable per repository across runs. - **Display name** - `GitHub: `. - **Template** - `steps`, with `progress`, `current_step`/`total_steps`, `step_rows`, and `step_labels`. `step_colors` and `step_weights` are added only when their opt-in flags are set. `live_progress` plus a `start_date`/`end_date` pair rides along on the update that advances `current_step`, and the result frames switch it back off. - **Accent color** - green while running, red on failure/cancel. - **Links** - primary URL is the workflow run's `html_url`; secondary URL is `https://github.com//`. ## Development This bridge is part of the `pushward-integrations` Go workspace (`go.work`), with a `replace` directive pointing `shared` at `../shared`. `go.mod` requires Go `1.26.5`. ```bash # Build from source (from the pushward-integrations workspace root) go build ./github/cmd/pushward-github # Or from inside this directory (github/) go build ./cmd/pushward-github # Run with a config file ./pushward-github -config github/config.example.yml # Tests (CI runs: -race -count=1 -v) go test ./github/... -race -count=1 -v # Lint (matches CI) golangci-lint run ``` ### Docker build The build context is the **repository root** (so the Dockerfile can `COPY shared/`), not the `github/` directory: ```bash docker build -f github/Dockerfile -t pushward-github . # Optionally pin the build-time Go version (Dockerfile default: 1.26.5) docker build -f github/Dockerfile --build-arg GO_VERSION=1.26.5 -t pushward-github . ``` ## CI/CD & Releases Bridges are versioned independently. The per-bridge workflow `.github/workflows/github-ci-cd.yml` runs on changes to `github/**` or `shared/**` and calls the shared `go-cicd-reusable.yml`. Images publish to **GHCR only** (`push_to_dockerhub: false`). | Trigger | GHCR tags published | |---|---| | Pull request | _(none - tests + analysis only)_ | | Push to `main` | _(none - tests + analysis only)_ | | Git tag `github/v` | `:X.Y.Z`, `:X.Y`, `:latest` (and `:X` once `X >= 1`) | `:latest` moves only on tagged releases - never on a `main` push. ```bash # Cut a release git tag github/v0.4.1 git push origin github/v0.4.1 ``` The release pipeline (`.github/workflows/release.yml`) produces a per-bridge GitHub Release with an auto-generated changelog (`.github/release.yml`). ## Server compatibility This bridge targets the [pushward-server](https://pushward.app) REST surface - `POST /activities` (create) and `PATCH /activities/{slug}` (seed / update / end) - via the hand-written shared `pushward.Client`. The contract (routes, JSON keys, auth headers) is owned by pushward-server's `openapi.yaml`; the bridge tracks it at `MAJOR.MINOR`, and patch releases are bridge-only fixes that need no coordinated server bump. Released iOS clients can't be hot-fixed, so the activity slug, template, and `ContentState` shape are part of the contract. ## Troubleshooting Logs are structured JSON written to stdout at info level. View them with `docker logs ` (or `docker compose logs -f pushward-github`); the startup line echoes the configured `owner`, `repos`, and `priority`. | Symptom | Likely cause / fix | |---|---| | `github.token is required` on startup | Set `PUSHWARD_GITHUB_TOKEN` (or `github.token`). | | `github.repos or github.owner is required` | Set at least one of `PUSHWARD_GITHUB_OWNER` / `PUSHWARD_GITHUB_REPOS`. | | `failed to create activity` / auth errors to PushWard | Wrong or missing `PUSHWARD_API_KEY` (must be a valid `hlk_` key with activity-manage), or wrong `PUSHWARD_URL`. | | GitHub `401`/`403`, or private repos not discovered | Token lacks `actions:read`, or lacks `repo` for private repositories. | | `configured poll rate exceeds the forge's hourly request budget` at startup | Too many repos for the configured `idle_interval`. Raise it, or narrow `github.owner` / `github.repos`. See [Request budget](#request-budget). | | `detection interval paced by the forge's request budget` | The window is running short, so new runs are noticed less often until it refills. Runs already being tracked are unaffected. Use a dedicated token if it is shared with heavy usage. | | `repo has no workflows` / `repo has no Actions` | Expected, and the point: that repo is skipped for half an hour rather than probed every pass. | | No Live Activity appears on the phone | No in-progress run yet, the iOS app isn't subscribed to that repo's slug, or no compatible iOS build is installed. | ## License `pushward-github` is published as part of the public [pushward-integrations](https://github.com/mac-lucky/pushward-integrations) repository. See the repository root for licensing. --- [![Website](https://img.shields.io/badge/pushward.app-5B4FE5?style=for-the-badge&logo=safari&logoColor=white)](https://pushward.app) [![App Store](https://img.shields.io/badge/App_Store-Download-0D96F6?style=for-the-badge&logo=apple&logoColor=white)](https://apps.apple.com/app/id6759689999) [![CI/CD SABnzbd](https://github.com/mac-lucky/pushward-integrations/actions/workflows/sabnzbd-ci-cd.yml/badge.svg)](https://github.com/mac-lucky/pushward-integrations/actions/workflows/sabnzbd-ci-cd.yml) [![Image](https://img.shields.io/badge/ghcr.io-pushward--sabnzbd-2496ED?logo=docker&logoColor=white)](https://github.com/mac-lucky/pushward-integrations/pkgs/container/pushward-sabnzbd) # PushWard for SABnzbd Tracks SABnzbd downloads and post-processing as a live-updating [PushWard](https://pushward.app) Live Activity on iPhone (Dynamic Island + Lock Screen). SABnzbd calls the bridge's webhook when an NZB is added; the bridge then polls the SABnzbd JSON API through the download and unpack phases and pushes progress, speed, ETA, and a completion summary to the PushWard server. > **New to PushWard?** Learn what it does at **[pushward.app](https://pushward.app)** and get the iOS app on the **[App Store](https://apps.apple.com/app/id6759689999)**. ## How it works ``` SABnzbd (NZB added) --webhook--> pushward-sabnzbd --REST--> PushWard server --APNs--> iOS Live Activity polls SABnzbd JSON API (queue + history) ``` `pushward-sabnzbd` is a **standalone (single-tenant) bridge**: it uses one PushWard integration key (`hlk_...`) for every push. On a webhook it creates one activity (slug `sabnzbd`, name "SABnzbd"), then polls SABnzbd on an interval and merge-patches the activity as progress changes. On startup it also resumes tracking if a download or unpack is already in flight, and dismisses any stale activity left over from a previous crash. ## Features - **Multi-file tracking** - shows the current filename and an `X/Y` counter when multiple NZBs are queued. - **Live download readout** - progress, speed (MB/s), and an ETA countdown derived from SABnzbd's `timeleft`. - **Post-processing phases** - Verifying, Repairing, Extracting, and Moving each render with a distinct SF Symbol icon. - **Completion summary** - total size, average speed, and unpack time, e.g. `1.2 GB Β· 45 MB/s avg Β· unpack 2m 3s`. - **Two templates** - `generic` (default) or `timeline`, which renders a download-speed sparkline. - **Paused state** - reflects SABnzbd pause/resume on the activity. - **Resume on startup** - picks up an in-progress download or unpack after a restart; otherwise clears a stale activity. - **Two-phase end** - sends a final ONGOING frame (so the APNs push-update token delivers it), then ENDED to dismiss. - **Change-detection heartbeat** - skips redundant pushes but pings at least every 30s so the server's stale TTL doesn't auto-end the activity. - **Resilient client** - circuit breaker (5 failures / 30s cooldown) plus 5x retry with exponential backoff on 5xx/network errors and `Retry-After` on 429. - **Webhook secret** - optional constant-time `X-Webhook-Secret` validation. - **Hardened by default** - non-root container (UID 1000), 1 MiB request body cap, and the SABnzbd API key redacted from logs. ## Prerequisites - A running **PushWard server** (public API: `https://api.pushward.app`). - A **PushWard integration key** (`hlk_` prefix). - A **SABnzbd** instance reachable over HTTP with its API key. - The **PushWard iOS app** installed and subscribed to the `sabnzbd` activity slug. ## Installation The published image is multi-arch (`linux/amd64`, `linux/arm64`). ### Docker ```bash docker run -p 8090:8090 \ -e PUSHWARD_SABNZBD_URL="https://sabnzbd.example.com/api" \ -e PUSHWARD_SABNZBD_API_KEY="YOUR_SABNZBD_API_KEY" \ -e PUSHWARD_URL="https://api.pushward.app" \ -e PUSHWARD_API_KEY="hlk_xxxxxxxxxxxx" \ ghcr.io/mac-lucky/pushward-sabnzbd:latest ``` The image entrypoint defaults to `-config /config/config.yml`. To use a file instead of env vars, mount one: ```bash docker run -p 8090:8090 -v ./config.yml:/config/config.yml:ro ghcr.io/mac-lucky/pushward-sabnzbd:latest ``` ### Docker Compose ```yaml services: pushward-sabnzbd: image: ghcr.io/mac-lucky/pushward-sabnzbd:latest ports: - "8090:8090" environment: - PUSHWARD_SABNZBD_URL=https://sabnzbd.example.com/api - PUSHWARD_SABNZBD_API_KEY=YOUR_SABNZBD_API_KEY - PUSHWARD_URL=https://api.pushward.app - PUSHWARD_API_KEY=hlk_xxxxxxxxxxxx # Optional but recommended when the webhook is reachable beyond localhost: - PUSHWARD_SABNZBD_WEBHOOK_SECRET=YOUR_WEBHOOK_SECRET # Optional: speed sparkline instead of the generic template: # - PUSHWARD_SABNZBD_TEMPLATE=timeline ``` Images are published to GHCR only: `ghcr.io/mac-lucky/pushward-sabnzbd`. ### Point SABnzbd at the webhook In SABnzbd, go to **Config -> Notifications -> Notification Script / URL** and POST to the bridge on the **Added NZB** event: ``` http://:8090/webhook ``` If you set `PUSHWARD_SABNZBD_WEBHOOK_SECRET`, every call must carry the same value in an `X-Webhook-Secret` header. SABnzbd itself has no field for the secret, so use a notification script that adds the header: ```bash #!/bin/sh curl -X POST -H "X-Webhook-Secret: YOUR_WEBHOOK_SECRET" http://:8090/webhook ``` The secret is defined only on the bridge (the env var above, or `sabnzbd.webhook_secret` in YAML); SABnzbd just sends it back with each call. Requests with a missing or wrong header get a 401. ## Configuration Settings come from a YAML file (`-config`) and/or environment variables. **Env vars override YAML.** The standardized env prefix is `PUSHWARD_*`. See [`config.example.yml`](./config.example.yml) for the canonical, commented file. | Env Variable | Config Key | Description | Required | |---|---|---|---| | `PUSHWARD_SABNZBD_URL` | `sabnzbd.url` | SABnzbd JSON API base URL (e.g. `http://host:8080/api`). The client appends `apikey`/`output`/`mode` query params. | **Yes** | | `PUSHWARD_SABNZBD_API_KEY` | `sabnzbd.api_key` | SABnzbd API key, sent as the `apikey` query parameter (SABnzbd 4.5+ has no header auth). Redacted from logs. | **Yes** | | `PUSHWARD_URL` | `pushward.url` | PushWard server base URL (public: `https://api.pushward.app`). | **Yes** | | `PUSHWARD_API_KEY` | `pushward.api_key` | PushWard integration key (`hlk_` prefix); the single tenant key for all pushes. | **Yes** | | `PUSHWARD_SABNZBD_WEBHOOK_SECRET` | `sabnzbd.webhook_secret` | Shared secret; when set, `/webhook` requires a matching `X-Webhook-Secret` header. Empty = unauthenticated (logged as a warning). | No (empty) | | `PUSHWARD_SABNZBD_TEMPLATE` | `sabnzbd.template` | Live Activity template: `generic` (default) or `timeline`. | No (`generic`) | | `PUSHWARD_SERVER_ADDRESS` | `server.address` | HTTP listen address for `/webhook`, `/health`, `/ready`. | No (`:8090`) | | `PUSHWARD_PRIORITY` | `pushward.priority` | Activity priority, validated 0-10. | No (`1`) | | `PUSHWARD_CLEANUP_DELAY` | `pushward.cleanup_delay` | Server `ended_ttl`: how long the finished activity lingers before the server deletes the row and iOS drops the Lock Screen card. Apple caps dismissal at 4h. `0` sends nothing and lets the server decide, so it does not mean "dismiss immediately". | No (`15m`) | | `PUSHWARD_DISMISSAL_DELAY` | `pushward.dismissal_delay` | Server `dismissal_ttl`: how long the ended card stays on the Lock Screen, independent of `cleanup_delay`, which governs deletion. `0` removes it the moment it ends. Unset leaves the server default (removal follows `ended_ttl`, capped at 4h). Rejected outside `0`-`4h`. | No (unset) | | `PUSHWARD_STALE_TIMEOUT` | `pushward.stale_timeout` | Server-side stale TTL (`staleTTL`) for the activity; 30s heartbeats keep it from auto-ending mid-download. | No (`30m`) | | `PUSHWARD_END_DELAY` | `pushward.end_delay` | Delay before phase 1 of the two-phase end (ONGOING with final content). | No (`5s`) | | `PUSHWARD_END_DISPLAY_TIME` | `pushward.end_display_time` | How long the completion frame shows before phase 2 (ENDED dismiss). | No (`4s`) | | `PUSHWARD_LOG_LEVEL` | _(env only)_ | `debug`, `info`, `warn` or `error`. Read before the config file, so it works even when config loading is what failed. | No (`info`) | | `PUSHWARD_POLL_INTERVAL` | `polling.interval` | SABnzbd poll interval during tracking; validated to be at least `1s`. | No (`5s`) | ### Timeline display (template: `timeline` only) These keys are **YAML-only** (no env override) and apply only when `sabnzbd.template` is `timeline`. The sparkline plots the `Speed` series in MB/s. ```yaml sabnzbd: template: "timeline" timeline: smoothing: true # smooth sparkline curve interpolation scale: "linear" # "linear" or "logarithmic" decimals: 0 # value-label precision, 0-10 ``` | Config Key | Description | Default | |---|---|---| | `sabnzbd.timeline.smoothing` | Smooth the sparkline curve. | `true` | | `sabnzbd.timeline.scale` | Y-axis scale: `linear` or `logarithmic`. | `linear` | | `sabnzbd.timeline.decimals` | Value-label precision (0-10). | `0` | > **Accepted but unused by this bridge:** `server.metrics_address` exists in the shared config but this bridge starts no metrics server, so there is nothing to scrape. ### Security note on `sabnzbd.url` SABnzbd accepts the API key only as a URL query parameter, so over plain `http://` an intermediate proxy or router could capture it from access logs. The bridge redacts the key from its own logs; if SABnzbd is exposed beyond a trusted network, front it with HTTPS. ## Endpoints | Method | Path | Description | |---|---|---| | `POST` | `/webhook` | Called by SABnzbd on NZB-added; starts/queues tracking. `200 {"status":"tracking_started"}`, or `{"status":"already_tracking"}` if a track is running. `405` on non-POST; `401` if a secret is configured and `X-Webhook-Secret` is missing/wrong. Body capped at 1 MiB. | | `GET` | `/health` | Liveness - always `200 ok`. | | `GET` | `/ready` | Readiness - `200 ready` (no readiness checks registered, so effectively always ready). | ## What the activity looks like The bridge maps one SABnzbd session to a single Live Activity (slug `sabnzbd`): 1. **Seed** - creates the activity (`endedTTL` = `cleanup_delay`, `staleTTL` = `stale_timeout`) and shows `Starting...`. 2. **Wait for start** - polls the queue a bounded number of times (12 polls ~ 60s at the default 5s interval; scales with `polling.interval`). A job held by SABnzbd's propagation delay reports an idle queue, so the bridge reads the slot status instead and shows `Waiting for propagation` until bytes flow. If the queue is genuinely idle and nothing is post-processing, it ends with `No downloads`. 3. **Download** - current filename + `X/Y` counter, progress, speed (MB/s), ETA, and a paused state. 4. **Post-processing** - Verifying / Repairing / Extracting / Moving with phase-specific icons. 5. **Continue** - if more downloads appear in the queue, loops back to the download phase. 6. **Complete** - green checkmark with a summary like `1.2 GB Β· 45 MB/s avg Β· unpack 2m 3s`; subtitle is the last completed name. 7. **End** - two-phase: a final ONGOING frame (so the push-update token delivers the summary), then ENDED to dismiss. Resumed sessions skip the dance and send ENDED directly. ## Development This bridge is one module in the `pushward-integrations` Go workspace (`go.work`); run these from the **repo root**. ```bash # Build the binary go build ./sabnzbd/cmd/pushward-sabnzbd # Run with a config file ./pushward-sabnzbd -config sabnzbd/config.example.yml # Test this bridge plus the shared module (matches CI) go test ./shared/... ./sabnzbd/... -race -count=1 -v # Lint (matches CI) golangci-lint run ``` Docker builds use the **repo root as the build context** with `-f sabnzbd/Dockerfile` so the Dockerfile can `COPY shared/`: ```bash # Build the image (note the trailing "." = repo root, not the sabnzbd dir) docker build -f sabnzbd/Dockerfile -t pushward-sabnzbd . # Optional: override the Go toolchain (Dockerfile default ARG GO_VERSION=1.26.5, matching go.mod) docker build -f sabnzbd/Dockerfile --build-arg GO_VERSION=1.26.5 -t pushward-sabnzbd . ``` ## CI/CD & Releases Bridges in this repo are versioned independently. Tag format is `sabnzbd/v` (e.g. `sabnzbd/v1.0.0`); pushing the tag runs the release pipeline and publishes the version images. A changelog is auto-generated via `.github/release.yml`. | Trigger | Tags published | |---|---| | Pull request | none (build/test only) | | Push to `main` | none (build/test only) | | Tag `sabnzbd/v` | `:X.Y.Z`, `:X.Y`, `:latest` (and `:X` once X >= 1) | `:latest` only moves on a tagged release. ## Server compatibility This bridge targets the PushWard server REST API and uses `POST /activities`, `PATCH /activities/{slug}` (full and merge-patch), and the APNs Live Activity `ContentState` shape. The contract owner is **pushward-server** (`openapi.yaml`); the shared `pushward.Client` is hand-written and kept in sync with it. Bridges track the server API surface at its **MAJOR.MINOR** - a patch release is a bridge-only fix. The released iOS clients cannot be hot-fixed, so the `sabnzbd` slug, content keys, and casing must stay stable. ## Troubleshooting Logs are structured JSON on stdout (`slog`, Info level). Read them with: ```bash docker logs -f pushward-sabnzbd ``` | Symptom | Cause / fix | |---|---| | `sabnzbd.url is required` / `sabnzbd.api_key is required` | Set `PUSHWARD_SABNZBD_URL` / `PUSHWARD_SABNZBD_API_KEY` (or the YAML keys). | | `polling.interval must be at least 1s` | Raise `PUSHWARD_POLL_INTERVAL` to `1s` or more. | | `webhook secret not configured -- webhook endpoint is unauthenticated` | Expected when no secret is set; set `PUSHWARD_SABNZBD_WEBHOOK_SECRET` to require `X-Webhook-Secret`. | | Webhook returns `401 unauthorized` | A secret is configured but the request's `X-Webhook-Secret` is missing or wrong. | | `SABnzbd never started downloading, giving up` | The queue stayed idle after the bounded wait and nothing was post-processing - check that SABnzbd actually queued the NZB and isn't paused. | | No Live Activity on the phone | Confirm the iOS app is subscribed to the `sabnzbd` slug, the `hlk_` key is valid, and `PUSHWARD_URL` points at your server. | | `fetching queue` / `unexpected status` errors | The bridge can't reach SABnzbd (5s HTTP timeout) or the API key is wrong - verify `sabnzbd.url` resolves and the key is correct (it is redacted in logs). | ## Requirements & License - Go 1.26.x (build), Docker (deploy). - A running PushWard server, a SABnzbd instance, and the PushWard iOS app. Part of the public [pushward-integrations](https://github.com/mac-lucky/pushward-integrations) repository - see the repository root for license details. --- [![Website](https://img.shields.io/badge/pushward.app-5B4FE5?style=for-the-badge&logo=safari&logoColor=white)](https://pushward.app) [![App Store](https://img.shields.io/badge/App_Store-Download-0D96F6?style=for-the-badge&logo=apple&logoColor=white)](https://apps.apple.com/app/id6759689999) [![CI/CD BambuLab](https://github.com/mac-lucky/pushward-integrations/actions/workflows/bambulab-ci-cd.yml/badge.svg)](https://github.com/mac-lucky/pushward-integrations/actions/workflows/bambulab-ci-cd.yml) [![Image](https://img.shields.io/badge/ghcr.io-pushward--bambulab-2496ED?logo=docker&logoColor=white)](https://github.com/mac-lucky/pushward-integrations/pkgs/container/pushward-bambulab) # PushWard for Bambu Lab Mirrors a Bambu Lab 3D printer's print progress as a [PushWard](https://pushward.app) Live Activity on iOS - progress bar, layer count, remaining time, and nozzle temperature live on the Dynamic Island and Lock Screen. Connects directly to the printer over local MQTT (no Bambu cloud), and reports paused, finished, failed, and cancelled prints in real time. > **New to PushWard?** Learn more at **[pushward.app](https://pushward.app)** and get the iOS app on the **[App Store](https://apps.apple.com/app/id6759689999)**. ## How it works ``` Printer (MQTT/TLS :8883) --> pushward-bambulab --> pushward-server (REST) --> APNs --> iOS Live Activity ``` The bridge connects to the printer over TLS MQTT on the local network, subscribes to `device//report`, and forces a full state snapshot on every connect (so delta-only P1/A1 printers don't hold stale state). It maps the printer's `gcode_state` to a PushWard **Live Activity** (`generic` template) via the server REST API - `POST /activities` to create, `PATCH /activities/{slug}` to update and end. The server pushes APNs updates to the [pushward-ios](https://pushward.app) app. The bridge exposes **no HTTP server or ports** - it is an outbound-only client. One Live Activity is maintained per printer, keyed by the constant slug `bambu-` (serial lowercased), and reused across prints. ## Features - **Real-time print tracking** - progress bar, `Layer N/M` (or `N%` when total layers are unknown), remaining-time countdown, filename, and nozzle temperature. - **Full print lifecycle** - preparing, printing, paused, finished, failed, cancelled, and interrupted states, each with its own icon and accent color. - **Local MQTT, no cloud** - connects directly to the printer over LAN; the Bambu cloud is never involved. - **Delta-state merging** - pointer-based merge keeps unsent fields from prior pushes, correctly handling P1/A1 delta-only reports; a `pushall` is re-sent on every (re)connect. - **TLS cert pinning** - pin the printer's self-signed cert by SHA-256 fingerprint, or rely on trust-on-first-use auto-pinning (default); insecure-skip-verify is opt-in only. - **Resilient startup** - retries the initial connect every 30s until the printer powers on, then relies on MQTT auto-reconnect. - **Auto-resume** - detects an in-progress print on startup (or once the first report arrives) and resumes tracking. - **Two-phase end** - shows the completion/failure frame on the Dynamic Island before dismissing the activity. - **Retry with backoff** - PushWard API calls retry up to 5 times with exponential backoff + jitter and honor `Retry-After` on 429. - **Graceful shutdown** - ends the active activity as `Interrupted` on `SIGINT`/`SIGTERM`, then disconnects MQTT. ## Prerequisites - A running **PushWard server** (public production base: `https://api.pushward.app`). - A PushWard **integration key** (`hlk_` prefix) with the `activity:manage` scope. - The **PushWard iOS app** (from the [App Store](https://apps.apple.com/app/id6759689999)), subscribed to the activity. - A **Bambu Lab printer** with **LAN-only Mode** and its **Developer Mode** option enabled (Developer Mode is what opens the local MQTT channel), reachable on your local network. You will need: - The printer's local **IP address** (or hostname). - The **Access Code**, read from the printer's screen - the exact path is model-dependent (Settings -> WLAN on P1P/P1S, the General tab in Settings on X1/H2, the LAN Only Mode screen on A1/A1 mini). Used as the MQTT password. - The printer's **serial number** - used in MQTT topics, the MQTT client ID, and the activity slug. > The access code (typically 8 characters) and serial (typically 15 characters) are Bambu hardware values. The bridge only checks they are non-empty - it does not validate their length or format. ## Installation Pull the published image (GHCR only): ```bash docker pull ghcr.io/mac-lucky/pushward-bambulab:latest ``` ### Docker ```bash docker run -d --name pushward-bambulab \ -e PUSHWARD_URL=https://api.pushward.app \ -e PUSHWARD_API_KEY=YOUR_API_KEY \ -e PUSHWARD_BAMBULAB_HOST= \ -e PUSHWARD_BAMBULAB_ACCESS_CODE= \ -e PUSHWARD_BAMBULAB_SERIAL= \ ghcr.io/mac-lucky/pushward-bambulab:latest ``` ### Docker Compose ```yaml services: pushward-bambulab: image: ghcr.io/mac-lucky/pushward-bambulab:latest restart: unless-stopped environment: - PUSHWARD_URL=https://api.pushward.app - PUSHWARD_API_KEY=YOUR_API_KEY - PUSHWARD_BAMBULAB_HOST= - PUSHWARD_BAMBULAB_ACCESS_CODE= - PUSHWARD_BAMBULAB_SERIAL= ``` The container runs as non-root (uid 1000) and reads its config from `/config/config.yml` by default (the `CMD` is `-config /config/config.yml`). Env vars alone are enough; to use a YAML file instead, mount it: ```yaml volumes: - ./config.yml:/config/config.yml:ro ``` ## Configuration All settings come from a YAML config file and/or environment variables. **Environment variables override YAML.** The standardized env prefix is `PUSHWARD_*`. See [`config.example.yml`](./config.example.yml) for the canonical file. ### Printer | Env Variable | Config Key | Description | Required | |---|---|---|---| | `PUSHWARD_BAMBULAB_HOST` | `bambulab.host` | Printer IP address or hostname on the local network. | Yes | | `PUSHWARD_BAMBULAB_ACCESS_CODE` | `bambulab.access_code` | Printer LAN access code (on the printer's LAN settings screen; path varies by model); used as the MQTT password (username `bblp`). | Yes | | `PUSHWARD_BAMBULAB_SERIAL` | `bambulab.serial` | Printer serial number; used in MQTT topics, client ID, and the activity slug `bambu-`. | Yes | | `PUSHWARD_BAMBULAB_CERT_FINGERPRINT` | `bambulab.tls.cert_fingerprint_sha256` | SHA-256 fingerprint of the printer's cert to pin (hex, optional `:` separators). See [TLS verification](#tls-verification). | No (default: empty -> auto-pin) | | _(no env override)_ | `bambulab.tls.insecure_skip_verify` | When `true`, accept any printer TLS cert (logs a startup warning). Only consulted when no fingerprint is set. | No (default: `false`) | ### PushWard | Env Variable | Config Key | Description | Required | |---|---|---|---| | `PUSHWARD_URL` | `pushward.url` | PushWard server base URL - `https://api.pushward.app`. | Yes | | `PUSHWARD_API_KEY` | `pushward.api_key` | Integration key (`hlk_`) with `activity:manage` scope; sent as `Authorization: Bearer`. | Yes | | `PUSHWARD_PRIORITY` | `pushward.priority` | Live Activity priority; must be `0`-`10`. | No (default: `1`) | | `PUSHWARD_CLEANUP_DELAY` | `pushward.cleanup_delay` | Server-side `ended_ttl`: how long ended activities linger before cleanup (Go duration). | No (default: `15m`) | | `PUSHWARD_DISMISSAL_DELAY` | `pushward.dismissal_delay` | Server `dismissal_ttl`: how long the ended card stays on the Lock Screen, independent of `cleanup_delay`, which governs deletion. `0` removes it the moment it ends; unset leaves the server default (follows `ended_ttl`, capped at 4h). | No (unset) | | `PUSHWARD_STALE_TIMEOUT` | `pushward.stale_timeout` | Server-side `stale_ttl`: auto-expiry for stuck/abandoned activities (Go duration). | No (default: `60m`) | | `PUSHWARD_END_DELAY` | `pushward.end_delay` | Delay before phase 1 (terminal `ONGOING` frame) of the two-phase end (Go duration). | No (default: `5s`) | | `PUSHWARD_END_DISPLAY_TIME` | `pushward.end_display_time` | How long the terminal frame shows before the `ENDED` frame is sent (Go duration). | No (default: `4s`) | | `PUSHWARD_LOG_LEVEL` | _(env only)_ | `debug`, `info`, `warn` or `error`. Read before the config file, so it works even when config loading is what failed. | No (default: `info`) | ### Polling | Env Variable | Config Key | Description | Required | |---|---|---|---| | `PUSHWARD_POLL_INTERVAL` | `polling.update_interval` | How often progress updates are sent / debounce interval for same-state ticks (Go duration). Validated to be **>= 2s**. | No (default: `5s`) | > State transitions (start, pause, finish, fail, cancel) are pushed immediately when MQTT delivers them; the poll interval only throttles in-progress progress frames and suppresses byte-identical content. ### TLS verification Bambu Lab printers serve a self-signed certificate with no public PKI. The bridge picks one of three modes, in precedence order: 1. **Fingerprint pin (recommended)** - set `bambulab.tls.cert_fingerprint_sha256` (or `PUSHWARD_BAMBULAB_CERT_FINGERPRINT`). TLS then verifies the cert with a constant-time fingerprint comparison. Extract the fingerprint with: ```bash openssl s_client -connect :8883 /dev/null \ | openssl x509 -fingerprint -sha256 -noout ``` Note: the fingerprint changes if the printer regenerates its cert (some firmware updates do this). 2. **Trust on first use (default)** - when no fingerprint is set and `insecure_skip_verify` is `false`, the bridge dials the printer once, captures the leaf cert's fingerprint, logs it, and pins it for the live MQTT connection. 3. **Skip verification** - set `bambulab.tls.insecure_skip_verify: true` to accept any cert. Logs a warning at startup. Only consulted when no fingerprint is set. ### Example config file ```yaml bambulab: host: "" # set PUSHWARD_BAMBULAB_HOST access_code: "" # set PUSHWARD_BAMBULAB_ACCESS_CODE serial: "" # set PUSHWARD_BAMBULAB_SERIAL tls: cert_fingerprint_sha256: "" # or set PUSHWARD_BAMBULAB_CERT_FINGERPRINT # insecure_skip_verify: true # last resort; accepts any cert pushward: url: "" # set PUSHWARD_URL (https://api.pushward.app) api_key: "" # set PUSHWARD_API_KEY (hlk_..., activity:manage scope) priority: 1 cleanup_delay: 15m stale_timeout: 60m polling: update_interval: 5s ``` ## Live Activity mapping Each `gcode_state` maps to one frame of the `generic` Live Activity template. The subtitle combines the print filename and nozzle temperature (`NN/NNΒ°C`) joined by ` Β· `. The activity title is the sliced file's base name, taken from `gcode_file` with the directory and the `.gcode`/`.3mf`/`.gcode.3mf` extension stripped. `subtask_name` is the fallback, because printers frequently report the slicer process preset there ("0.2mm layer, 6 walls, 20% infill") rather than the model name. Plates sent straight from Bambu Studio arrive as `/data/Metadata/plate_1.gcode` and fall back too, since a plate number says no more than the preset does. | Printer state | Activity state | State text | Icon | Color | |---|---|---|---|---| | `PREPARE` | `ongoing` | `Preparing...` | `arrow.triangle.2.circlepath` | blue | | `RUNNING` | `ongoing` | `Layer N/M` (or `N%`) | `printer.fill` | blue | | `PAUSE` | `ongoing` | `Paused` | `pause.circle.fill` | orange | | `FINISH` | `ended` (two-phase) | `Complete` | `checkmark.circle.fill` | green | | `FAILED` | `ended` (two-phase) | `Failed` | `xmark.circle.fill` | red | | `IDLE` (while tracking) | `ended` | `Cancelled` | `xmark.circle.fill` | orange | | `SIGINT`/`SIGTERM` (while tracking) | `ended` | `Interrupted` | `xmark.circle.fill` | orange | ## Development Run all commands from the repository root (`pushward-integrations/`), which is a Go workspace. ```bash # Build go build ./bambulab/cmd/pushward-bambulab # Run with a config file (env vars override YAML) ./pushward-bambulab -config bambulab/config.example.yml # Test (matches CI: race + verbose) go test ./bambulab/... ./shared/... -race -count=1 -v # Lint (matches CI) golangci-lint run ``` ### Docker build The Docker build context is the **repository root** (not the bridge directory) so the Dockerfile can `COPY shared/`: ```bash # Build (default Go toolchain ARG is 1.26.5) docker build -f bambulab/Dockerfile -t pushward-bambulab . # Pin the Go toolchain to match go.mod docker build --build-arg GO_VERSION=1.26.5 -f bambulab/Dockerfile -t pushward-bambulab . ``` ## CI/CD & Releases - **CI** runs on every push/PR touching `bambulab/**`, `shared/**`, or the workflow file: Go tests (`-race -count=1 -v`), lint, and an image build. - **Bridges are versioned independently.** Tag format: `bambulab/v`. Pushing the tag triggers the release pipeline and a GitHub Release with auto-generated notes. - Images are published to **GHCR only** (`ghcr.io/mac-lucky/pushward-bambulab`); Docker Hub publishing is disabled for this bridge. | Trigger | Tags published | |---|---| | Pull request | none (build only) | | Push to `main` | none (build only) | | Git tag `bambulab/v` | `:X.Y.Z`, `:X.Y`, `:latest` (and `:X` once X >= 1) | `:latest` only moves on a tagged release. ## Server compatibility This bridge is an outbound REST client of [pushward-server](https://pushward.app). It targets the Activities API surface - `POST /activities` and `PATCH /activities/{slug}` with `Authorization: Bearer ` - defined by the server's `openapi.yaml` (the contract owner). The shared `pushward.Client` is hand-written; it tracks the server's MAJOR.MINOR API. Patch releases of this bridge are bridge-only fixes and require no server change. ## Troubleshooting Logs are structured JSON on stdout at `Info` level. With Docker: `docker logs -f pushward-bambulab`. | Symptom | Likely cause / fix | |---|---| | `failed to connect to printer, retrying` (every 30s) | Printer is off, unreachable, or LAN/Developer Mode is disabled. The bridge retries until it appears - power on the printer or fix the network. | | `MQTT connect` auth errors | Wrong `access_code`. Re-read it from the printer's LAN settings screen (path varies by model). | | `peer cert fingerprint mismatch` | The pinned `cert_fingerprint_sha256` no longer matches (printer regenerated its cert). Re-extract the fingerprint, or clear it to fall back to auto-pin. | | `BambuLab TLS verification disabled via insecure_skip_verify` (warning) | Expected only if you set `insecure_skip_verify: true`. Prefer fingerprint pinning. | | No activity appears on iPhone | Check `PUSHWARD_URL`/`PUSHWARD_API_KEY`, that the key has `activity:manage` scope, and that the iOS app is installed and subscribed. | | `polling.update_interval must be >= 2s` | Raise `update_interval` (or `PUSHWARD_POLL_INTERVAL`) to at least `2s`. | | `pushward.priority must be 0-10` | Set `priority` within `0`-`10`. | ## Requirements - Go **1.26+** (the module declares `go 1.26.5`). - A reachable Bambu Lab printer (MQTT/TLS on port `8883`) and a running PushWard server. ## License Part of the [pushward-integrations](https://github.com/mac-lucky/pushward-integrations) repository - see the repository for license terms. --- [![HACS](https://img.shields.io/badge/HACS-Default-41BDF5.svg?style=for-the-badge)](https://hacs.xyz) [![Website](https://img.shields.io/badge/pushward.app-5B4FE5?style=for-the-badge&logo=safari&logoColor=white)](https://pushward.app) [![App Store](https://img.shields.io/badge/App_Store-Download-0D96F6?style=for-the-badge&logo=apple&logoColor=white)](https://apps.apple.com/app/id6759689999) # PushWard for Home Assistant [![CI](https://github.com/mac-lucky/pushward-hass/actions/workflows/ci.yml/badge.svg)](https://github.com/mac-lucky/pushward-hass/actions/workflows/ci.yml) [![Release](https://img.shields.io/github/v/release/mac-lucky/pushward-hass?sort=semver)](https://github.com/mac-lucky/pushward-hass/releases) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) Mirror Home Assistant entities onto iPhone via [PushWard](https://pushward.app), as **Live Activities** (Dynamic Island + Lock Screen) and **Home/Lock Screen widgets**, plus account usage sensors and services to send push notifications, transactional email, and activity/widget updates from your automations. > **New to PushWard?** Learn more at **[pushward.app](https://pushward.app)**. The iOS app is on the **[App Store](https://apps.apple.com/app/id6759689999)**; you control everything from this integration and your automations. ## What it looks like The entity you track shows up on the phone as a Live Activity, a Home/Lock Screen widget, or both.

Two Live Activities on the iPhone Lock Screen: a Bambu Lab print timeline and a SABnzbd download PushWard widgets on the iPhone Home Screen showing pages, deploys, errors, incident, and on-call The PushWard iOS app listing active Live Activities

Lock Screen Live Activities  |  Home Screen widgets  |  the in-app activity list. More examples at pushward.app.

## Contents [What it looks like](#what-it-looks-like) Β· [How it works](#how-it-works) Β· [Features](#features) Β· [Prerequisites](#prerequisites) Β· [Installation](#installation) Β· [Configuration](#configuration) Β· [Account sensors](#account-sensors) Β· [Services](#services) Β· [Domain Defaults](#domain-defaults) Β· [Contributing](#contributing) Β· [Support](#support) Β· [Translations](#translations) Β· [Development](#development) Β· [CI/CD & Releases](#cicd--releases) Β· [Server compatibility](#server-compatibility) Β· [Troubleshooting](#troubleshooting) Β· [Requirements & License](#requirements--license) ## How it works The integration watches HA entity state changes and surfaces them on your iPhone two independent ways, while polling your account's own usage counters for sensors: ```mermaid flowchart LR HA["HA entity state change"] AUTO["Automations"] AM["ActivityManager"] WM["WidgetManager"] SVC["pushward.* services"] API["PushWard API (api.pushward.app)"] APNS["APNs"] IOS["iPhone: Live Activity, widget, push, email"] SENS["Account usage and quota sensors"] HA --> AM HA --> WM AUTO --> SVC AM --> API WM --> API SVC --> API API --> APNS APNS --> IOS API -.->|"GET /auth/me every 15 min"| SENS ``` - **Live Activities**: when an entity enters a configured *start* state (e.g. the washer turns on), a Live Activity appears; on an *end* state it dismisses with a two-phase completion animation. Each tracked entity is a `tracked_entity` subentry. - **Widgets**: an entity (or several, for `stat_list`) is bound to a server-rendered Home/Lock Screen widget that re-renders on state change or on a poll interval. Each widget is a `tracked_widget` subentry. The two surfaces are independent (separate config, managers, and caches) and share only the API client and icon/color resolution. ## Features - **Track any HA entity** as a PushWard Live Activity (Dynamic Island + Lock Screen) - **10 activity templates**: generic, countdown, alert, steps, gauge, timeline, board, log, media, approval - **10 widget templates**: value, progress, gauge, status, stat_list, trend, countdown, battery, schedule, flow - **Two widget trigger modes**: `event` (state-change) or `poll` (10-3600 s interval), plus an optional staleness heartbeat that keeps a rarely-changing widget from greying out - **Account usage sensors**: notifications, Live Activity updates, widget updates, and emails consumed vs. plan limits, plus subscription tier - **Template auto-suggestion** picks the best activity template from entity domain and device class - **14 domain defaults**: pre-filled start/end states and a default icon per HA domain - **Companion source entities**: read remaining time, progress, value, etc. from a *separate* entity - **Two-phase end** shows a completion state (green checkmark) before dismissing - **Live-progress ETA** fills the progress bar smoothly and counts down to a finish time (generic + steps) - **Activity artwork** beside the icon (generic + steps + media), with an inline ThumbHash computed here so a picture that only Home Assistant can reach still renders on the phone - **Tracked media players** as a player card: cover art read off the entity, a scrubber that ticks on the device, and transport buttons that call back into Home Assistant - **Throttled updates** with content deduplication - **6-level icon fallback**: attribute -> config -> entity -> registry -> device class -> domain default - **Color support**: RGB, HSV, XY, Kelvin, named colors - **TTL controls**: auto-delete after end, auto-end on stale activity, auto-dismiss from the Lock Screen - **Services** for the whole surface: create/update/end/delete activities (with a per-template update action), send notifications, send email, refresh/delete widgets, generate a ThumbHash ## Prerequisites - **Home Assistant 2025.7.0** or newer - A **PushWard account** with an **integration key** (`hlk_` prefix), created in the PushWard iOS app under **Settings > Integration Keys** (recommended scope `ha-*`) - The key needs the **`widgets`** permission to publish widgets - The key needs the **`emails`** capability *and* a verified recipient to use `send_email` - The **[PushWard iOS app](https://pushward.app)** installed on the iPhone that will display the Live Activities / widgets ## Installation ### HACS (recommended) [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=mac-lucky&repository=pushward-hass&category=integration) PushWard is in the **default HACS store** - no custom repository needed. The button above opens it directly in HACS; then download and restart. Or do it manually: 1. Open **HACS** in Home Assistant 2. Search for **PushWard** 3. Open it and click **Download** 4. **Restart Home Assistant** ### Manual Copy the `custom_components/pushward` directory into your Home Assistant `config/custom_components/` folder and restart Home Assistant. ## Configuration Setup is UI-driven (config flow). The **only** value you enter is your integration key; the server URL is fixed to `https://api.pushward.app` and is not user-editable. [![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=pushward) 1. Go to **Settings > Devices & Services > Add Integration** 2. Search for **PushWard** 3. Paste your **integration key** (validated against `GET /auth/me`) Once the entry exists, add tracked entities and widgets through the integration's **Configure** / **Add tracked entity** / **Add tracked widget** subentry flows. The key can be replaced later via **Reconfigure**, and the integration auto-prompts for reauth if the key becomes invalid. | Setting | Required | Default | Description | |---------|:--------:|---------|-------------| | Integration key | Yes | - | PushWard key (`hlk_` prefix). Stored on the config entry; validated on setup. | | Server URL | No | `https://api.pushward.app` | Fixed by the integration; not shown in the UI. | Every field label and its help text is shown live in the Home Assistant config-flow UI, so the tables below are a reference, not something you need to read before setting up. ### Add a tracked entity (Live Activity) A two-step flow. **Step 1** picks the entity and a template (a better template is auto-suggested from the entity's domain/device class): | Template | Use case | |----------|----------| | `generic` | Flexible: progress bar, subtitle, icon | | `countdown` | Timer with remaining time and end date | | `alert` | Severity-based notification (critical/warning/info) | | `steps` | Multi-step process (e.g. build stages) | | `gauge` | Numeric value with a range (e.g. temperature, battery) | | `timeline` | Sparkline chart, up to 10 named series from attributes or separate entities | | `board` | 1-4 tiles, each showing a value from a **separate** entity | | `log` | Newest-first list of log lines (up to 20), one per state change | | `media` | Player card for a `media_player`: cover art, a ticking scrubber, transport buttons | **Step 2** configures the details (fields vary by template).
All Step 2 fields | Field | Description | |-------|-------------| | Slug | Unique ID, max 128 chars (auto-generated from entity if blank) | | Activity Name | Display name on iPhone | | Icon / Icon Attribute | Static MDI/SF Symbol, or an entity attribute for a dynamic icon | | Priority | 0-10 (default: 1) | | Start / End States | States that trigger start or end | | Update Interval | Min seconds between updates (default: 5) | | Progress Entity / Attribute | 0-100 progress, optionally from a separate entity | | Live Progress ETA | Fill the progress bar smoothly and count down an ETA to the finish time, using the remaining-time source (generic/steps templates; on steps it fills the current step) | | Image URL / Shape / ThumbHash | Artwork beside the icon (generic/steps/media templates) - see [Activity images](#activity-images). On a media player, leave it empty and the cover art fills in | | Remaining Time Entity / Attribute | Seconds remaining (countdown), with smart time parsing | | Total Steps / Current Step Entity / Attribute | Steps tracking, optionally from a separate entity | | Step Details | One row per step: label, row height (1-10), relative width, and color (steps template) | | Severity | critical, warning, or info (alert template) | | Value Entity / Attribute | Numeric value (gauge/timeline), optionally from a separate entity | | Min / Max Value | Gauge range bounds (default: 0-100) | | Unit | Display unit (e.g. Β°C, %) | | Series | Rows mapping a tracked-entity attribute to a series label (multi-series timeline) | | Series Entities | Rows binding a separate entity as a timeline line (entity, optional attribute and label), max 10 total | | Primary Series | Label of the series shown as the headline value and used for the compact high/low range; empty = the tracked entity's own series (or the first configured one) | | Per-Series Units | Rows mapping a series label to its unit (timeline template) | | Scale / Decimal Places / Smooth Lines / Thresholds | Timeline sparkline options (Thresholds is a row table: value, optional color, optional label) | | Back-History Period | Minutes of history to seed the sparkline on start (0-14400, up to 10 days; numeric sensors pull from the recorder, bounded by its retention). Points are downsampled evenly to keep the full span. | | Board Tiles | Rows binding a separate entity to a tile (label, entity, attribute, unit, icon, color, URL), max 4 (board template) | | Log Columns | Rows adding extra values to each log line (label, entity, attribute, unit), max 6 (log template) | | Log Level Attribute | Attribute supplying each line's `info`/`warn`/`error` level (log template) | | Transport Buttons | Show previous/play-pause/next/stop/volume on the card, filtered to what the player supports (media template) | | Favorite Script | Script the heart button runs; hidden when empty (media template) | | Subtitle Entity / Attribute | Subtitle text, optionally from a separate entity | | State Labels | Rows giving custom display text per state (a state and its label, e.g. `on` shows `Running`) | | Completion Message | Text shown at end (default: "Complete") | | Accent / Background / Text Color (+ Attribute) | Static hex / named color, or an entity attribute | | URL / Secondary URL | Deep-link URLs, http/https (steps/alert templates) | | Ended TTL / Stale TTL / Dismissal TTL | Auto-delete-after-end / auto-end-after-idle (1-2592000 s) / auto-dismiss the ended activity from the Lock Screen (0-14400 s) | Board tiles, stat rows, series entities, thresholds, log columns, state labels, the timeline series and per-series units maps, and the per-step details are all edited as row tables in the UI (add a row per entry). Stored configs and non-form callers keep working: the older comma-separated string forms for these fields are still accepted on input.
#### Reading values from separate entities By default every value (remaining time, progress, subtitle, gauge value, current step, fired-at) is read from the **tracked entity**, its state or one of its attributes. Many appliances expose these as **separate entities** (e.g. an LG washer has one sensor for the program state and another for remaining time). For each value you can set an optional **source entity**: - **Source entity empty** -> read from the tracked entity (default). - **Source entity set, attribute empty** -> read that entity's **state**. - **Source entity set, attribute set** -> read that **attribute** of the source entity. **Smart time parsing**: the remaining-time source accepts a `timestamp`/finish-time sensor (anchors the end date directly, no drift), a `duration` sensor with a unit (`s`/`min`/`h`/`d`), an `H:MM:SS`/`MM:SS` string, or a plain number of seconds.
Multi-entity timeline series A **timeline** can plot up to **10 named series** on one chart, each its own line, color, and unit. There are two ways to supply them, and they combine: - **Series** is a row table, one row per attribute of the tracked entity, mapping the attribute to a series label. - **Series Entities** is a row table that binds *separate* entities as lines, so values from unrelated sensors share one chart (a PM2.5 sensor per room, solar arrays, etc.). Each row takes an **entity** (required; its state, or an **attribute** you name) and an optional **label**. Left off, the label defaults to the entity's friendly name (with the attribute name appended for attribute sources so two attributes of one entity stay distinct). Labels are frozen when you save (the server merges series by label), truncated to 32 chars, and de-duplicated with a numeric suffix. Each series entity is tracked as a companion, so a change to any one re-samples the chart while the anchor entity owns start/end. Units auto-default from each state-sourced entity's `unit_of_measurement`; the **Per-Series Units** table (a series label and its unit per row) overrides them. Numeric attributes in the 0-255 range (e.g. `brightness`) are rescaled to 0-100. The 10-line cap covers Series and Series Entities combined; the server and iOS app already render multi-series timelines, so this is a Home Assistant configuration option only.
Timeline sparkline backfill **Back-History Period** seeds the sparkline when the activity starts. What can be seeded depends on where each series reads its value: - **State-sourced series** (a plain numeric sensor, a value entity, or a series entity read as a state) backfill from Home Assistant's recorder in one batched query, so they fill in immediately on start. - **Attribute-based series** (Series attribute maps, a value attribute, or a series entity read as an attribute) cannot use the recorder: Home Assistant 2024.8 [removed most attributes from the recorder](https://github.com/home-assistant/core/issues/123028). These fill only from samples the integration collects live while it runs. For attribute-based history the integration keeps its own in-memory ring buffer (max 300 samples per entity), populated from live state changes and persisted to `.storage/pushward.history.` so it survives restarts. That buffer is empty right after install and fills as the tracked attribute changes, at state-change resolution (no polling). Recorder points and buffered points are merged by timestamp into the same series, so a numeric sensor gets both its recorded past and any live samples. If your value lives in an attribute and you want recorder backfill, expose it as a template sensor's state.
Board tiles (multi-entity) A **board** shows a compact grid of **1-4 tiles**, each reading a *separate* entity. The **anchor entity** (step 1) still owns the activity lifecycle through its start/end states; the tiles supply the displayed values, and a change to any tile entity refreshes the board while it is active. Add one row per tile in the **Board Tiles** table: - **Label** (required, max 32 chars) and **Entity** (required) are the minimum. - **Attribute** (optional) reads that attribute instead of the entity state. - **Unit** (optional, max 8 chars), **Icon** (optional; an SF Symbol like `cpu.fill` or an MDI icon like `mdi:thermometer`), **Color** (optional named or hex), and **URL** (optional per-tile tap target) follow. Each tile **value** is rendered as text (so `Open`, `On`, and numbers all work) and capped at 16 chars. Tiles whose entity is unavailable are skipped.
Log lines A **log** shows a newest-first list of up to **20 lines**. The integration appends one line on every state change of the tracked entity (the line **text** is the formatted state, honoring State Labels), accumulating a rolling buffer that is injected into each push and persisted across restarts in `.storage/pushward.history.`. Set the optional **Log Level Attribute** to an attribute holding `info`, `warn`, or `error` to tag each line's severity. (The server also keeps a longer scrollable backlog server-side; the integration never sends it.) Consecutive lines with identical text are collapsed, so attribute-only churn (a light's brightness settling while its state stays `on`) would otherwise show only a bare `On`. Use the **Log Columns** table to append extra values to each line so it carries *what* changed: attributes of the tracked entity and/or values from other entities. Add one row per column (max 6), each with an optional **Label** and **Unit** plus a source: - **Entity** empty, **Attribute** set reads that attribute of the tracked entity (`brightness`). - **Entity** set, **Attribute** empty reads that entity's state (`binary_sensor.door`). - **Entity** and **Attribute** both set reads that attribute of the other entity. **Label** (optional) renders the column as `Label: value`; **Unit** (optional) is appended to the value as a literal suffix (no conversion). Each line's text is the state label followed by ` Β· ` and each resolved column. Values are raw Home Assistant values (e.g. `brightness` is 0-255). Columns whose source is missing or unavailable are skipped; if every column resolves empty (e.g. the lamp is off so `brightness` is absent) the line falls back to just the state label. Other-entity columns are tracked as companions, so a change in any one appends a new composed line while the tracked entity still owns start/end. Example for a lamp: a `K`-suffixed column reading `color_temp_kelvin` plus a bare `brightness` column render lines like `On Β· 4000K Β· 153`, and a brightness change now produces a distinct line instead of collapsing into the previous `On`.
Tracked media players Pick a `media_player` in step 1 and the **media** template is suggested for it. The card shows the track title, the artist under it, the cover art, a scrubber the phone ticks forward on its own, and a row of transport buttons. Everything on it is read from the player's own attributes, so there is nothing to map: - **Title**: `media_title`, else `media_series_title`, else `media_channel`, else `source`. - **Subtitle**: `media_artist`, else `media_album_name`, else `app_name`. Setting a Subtitle Entity or Attribute overrides that chain. - **Scrubber**: `media_position` paired with `media_position_updated_at`, plus `media_duration`. The position is sent *with* the moment it was read, and iOS advances it from there while the state is playing - so the bar keeps moving between pushes. A player that reports a position but not the timestamp gets no scrubber rather than a wrong one, and a stale anchor (a player paused since yesterday) is dropped for the same reason. - **Volume**: `volume_level`. - **Playback state**: `playing`, `paused` and `buffering` map straight through; anything else reads as stopped. Start/end states default to **playing, buffering** and **off, idle, standby**. `paused` is deliberately in neither: while the card is up, pausing updates it rather than dismissing it, and while it is down, pausing starts nothing. **Cover art** comes from the player itself. `entity_picture` on a media player is usually a signed proxy path, which the phone can neither reach nor authenticate, so the integration reads the image bytes straight off the entity and sends a [ThumbHash](https://evanw.github.io/thumbhash/) inline with the activity. The picture path carries a per-track cache key, so a whole album costs one decode per track rather than one per push. Set an Image URL by hand and that picture wins instead. **Transport buttons** call back into Home Assistant. Each button carries a URL like `https:///api/pushward/media//next?token=`; pressing it POSTs silently (no app opens) and the integration runs `media_player.media_next_track` on the player. Only the buttons the player advertises through `supported_features` are sent, so a card never shows a button that would fail on press. `play_pause` needs both the play and pause capabilities, which is what Home Assistant's own `media_play_pause` service requires. Two things worth knowing before you leave the buttons on: - The callback **cannot** use your Home Assistant login: the request comes from the phone with only what the notification carried. The per-player token in the URL is the whole credential, so anyone who gets hold of that URL can drive that player - and run the favorite script, if you configured one. Turn **Transport Buttons** off for a display-only card; the endpoint then stops answering for that player too, and the next update removes the buttons from cards already on a Lock Screen. - It needs an **https** URL Home Assistant knows about (Settings > System > Network) - the external URL normally, though an https internal URL works for phones on the same network or a VPN. Without one the buttons are simply left off and a warning is logged once; a plain-http URL counts as none, because iOS refuses cleartext requests from the extension that fires these buttons. The **Favorite Script** option adds a heart button that runs a script you name - media players have no favorite of their own, so what "favorite" means is up to that script (starring the track in Spotify, adding it to a playlist, whatever the app supports).
### Add a tracked widget A two-step flow mirroring entities. **Step 1** picks the entity, a widget template, and an optional slug override: | Template | Use case | |----------|----------| | `value` | A single numeric value | | `progress` | A value rendered as a progress bar, or a start/end window that advances on its own | | `gauge` | A value within a min/max range | | `status` | A label/icon status (optionally severity-colored) | | `stat_list` | Up to 6 rows, each bound to a **separate** entity | | `trend` | A sparkline of the last 2-48 samples plus the current value | | `countdown` | Counts down to a date, then shows your expired text | | `battery` | Up to 8 device rings, each bound to a **separate** entity | | `schedule` | Up to 48 periods on a timeline (hourly tariffs, delivery windows, shifts) | | `flow` | What comes in, what buffers it, what is traded, what consumes it | `countdown` here is the widget template, unrelated to the activity template of the same name. **The last five templates need the PushWard iOS app 1.6.0 or newer.** Older builds cannot decode them, and a single entry they cannot decode makes the entire widget list unavailable in the app until that widget is deleted -- not just the one new widget. Update every device on the account before adding one, especially if the account is shared with a device you have not opened in a while. The original five templates are unaffected. **Step 2** configures the widget (publishing widgets requires the `widgets` key permission).
All widget Step 2 fields | Field | Description | |-------|-------------| | Widget Name | Display name | | Value Attribute | Source attribute (value/progress/gauge); blank = entity state | | Unit | Display unit (value/progress/gauge) | | Min / Max Value | Gauge range bounds (default: 0-100) | | Severity | "", info, warning, critical, success (status template) | | Stat Rows | Rows binding a separate entity to a stat (label, entity, attribute, unit, timer), max 6 (stat_list) | | Trend History | Minutes of recorder history to seed the sparkline on start; 0 skips the seed (trend) | | Start / End Date Attribute | Attributes holding the window ends (countdown, progress) | | Expired Text | Shown once the end date passes, max 64 chars (countdown) | | Devices | Rows binding a separate entity to a battery ring, max 8 (battery) | | Period Attributes / Start Key / Value Key | Where to read the period arrays and their keys (schedule) | | Low Band Maximum / High Band Minimum | Optional band thresholds; leave both empty to let iOS derive them (schedule) | | Flow Nodes | Rows binding a separate entity to a slot, max 3 inputs (flow) | | Label / Label Attribute | Static label or an entity attribute | | Subtitle Attribute | Subtitle text from an attribute | | Timer Entity / Attribute / Style | Render the subtitle as a live countdown or count-up | | Icon / Icon Attribute | Static MDI/SF Symbol or an entity attribute | | Accent / Background / Text Color (+ Attribute) | Colors, static or from an attribute | | Tap Action URL / Foreground | Deep link opened when the widget is tapped | | Trigger Mode | `event` (state-change) or `poll` | | Poll Interval | Seconds between re-evaluations in poll mode (10-3600, default 60) | | Stale After | Seconds before iOS greys the widget out; blank = never (60-604800) |
#### Battery rings Each row binds one entity: **Name**, **Entity**, and optionally an **Attribute** (blank reads the state), a **Charging entity** (a binary sensor; `on` overlays the bolt), an **Icon** and a **Color**. Leave the name blank to fall back to the entity's friendly name. The level is clamped to 0-100, and a row whose entity is unavailable or non-numeric is skipped rather than failing the whole widget, so one flat sensor doesn't take the board down with it. #### Flow slots and signs A flow row's **Slot** decides where it renders: up to three `input` rows, plus one each of `output`, `storage` and `exchange`. `Rate` comes from the row's entity (or its attribute); `Total entity` supplies a cumulative total for the day and `Level entity` a 0-100 fill for a storage node. The sign convention is yours to choose, but the iOS rendering assumes the energy one: **exchange is positive inbound** (importing) and negative outbound (exporting), and **storage is positive while filling** and negative while draining. Nothing in the template is energy-specific though - water, data and money use the same four slots. #### Schedule periods **Period Attributes** is a comma-separated list of attributes on the tracked entity, each holding a list of period dicts. They are concatenated in the order given, sorted by start, and de-duplicated (a later array wins on a repeated start, which is what a tomorrow array overlapping today's tail wants). Nordpool is the usual source: ```yaml Period Attributes: raw_today, raw_tomorrow Period Start Key: start Period Value Key: value ``` Set **Low Band Maximum** and **High Band Minimum** to colour the bands yourself; leave both empty and the app derives them from the range you posted. Past 48 periods the oldest are dropped first, keeping the period covering now plus everything after it. #### Trend history A trend widget needs at least two points before it can render, so a brand-new one defers its first push until a second sample arrives. Set **Trend History** to seed it from the recorder instead: entities with a `state_class` read pre-aggregated statistics, the rest read raw states. The buffer keeps up to 300 samples, downsampled to the 48 the wire allows, and persists across restarts so a reload doesn't flatten the chart. #### Self-advancing progress Give a `progress` widget a **Start Date Attribute** and an **End Date Attribute** and the bar advances on the device between pushes, with no quota cost. Send a value as well when you have one - older app builds only read the value. #### Timers **Timer Entity / Attribute / Style** renders the subtitle as a live countdown (future date) or count-up (past date), re-rendered by iOS itself rather than by a push. `timer` ticks like `01:23:45`; `relative` shows coarse units like `2 min`. Stat rows get the same treatment per row: set a row's **Timer** and, when its value parses as a date, it renders as a timer while the plain string stays as the fallback. #### Staleness and the heartbeat **Stale After** tells iOS how long after the last update the widget should render as stale. Because that clock keeps running even when nothing in HA changes, setting it also arms a heartbeat: every `stale_after / 2` seconds (minimum 30) the integration re-sends the current content. Identical content is a no-op server-side that re-stamps `updated_at` without pushing to the device. **Each heartbeat still spends one widget update from your quota**, so keep the value at 3600 or above unless you genuinely need a tighter freshness window - 3600 costs about 1,440 widget updates a month per widget. Leave the field blank and the widget is never marked stale and no heartbeat runs. How many `stat_list` rows are visible depends on the widget size. By default a medium or large Home Screen widget shows all 6 rows; the small widget shows 4 and the Lock Screen rectangular shows 3, packing in up to 6 when every value is very short (for example a single status glyph). You can change Row Density per widget in the PushWard iOS app: Compact packs two columns to show up to 6 rows on any size (labels may truncate on the small placements), and Comfortable keeps a single column with larger rows. To see all 6 rows with full labels, use a medium or large widget, or set Compact. ## Account sensors Each config entry registers **5 sensors** under one service device named **PushWard**, fed by a coordinator that polls `GET /auth/me` every **15 minutes**. They report your account's own consumption against its plan limits (these sensors stay *unavailable* on older servers that don't return usage to integration keys): | Sensor | State | Attributes | |--------|-------|------------| | Notifications used | Count this period (`TOTAL_INCREASING`) | `limit`, `remaining`, `percent_used`, `period`, `resets_at`, plus `used_this_month`, `daily_resets_at` on premium | | Live Activity updates used | Count this period | `limit`, `remaining`, `percent_used`, `period`, `resets_at` | | Widget updates used | Count this period | `limit`, `remaining`, `percent_used`, `period`, `resets_at` | | Emails used | Count this period | `limit`, `remaining`, `percent_used`, `period`, `resets_at` | | Subscription tier | `free` or `premium` (ENUM) | - | On premium, uncapped resources report `limit: unlimited`, and the notifications counter switches to a daily cap (hence `used_this_month` / `daily_resets_at`). ## Services All services live in the `pushward` domain. There are 19 in total: the nine below plus a per-template `update_activity_