Skip to content

Activities

Activities are the core resource in PushWard. Each activity represents a trackable item that can be displayed as a Live Activity on subscribed devices.

Info

Each user can have a maximum of 25 activities. Attempting to create more returns 409 with a Problem Details 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

FieldTypeRequiredDescription
slugstringYesURL-safe identifier, unique per user
namestringYesHuman-readable display name
priorityintegerNoEviction 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_ttlintegerNoSeconds after ended transition before server auto-deletes the activity
stale_ttlintegerNoSeconds of inactivity while ongoing before server auto-ends the activity
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,
  "delete_at": null,
  "created_at": "2025-06-15T10:30:00Z",
  "updated_at": "2025-06-15T10:30:00Z",
  "ended_at": null,
  "share_role": null,
  "owner_id": null,
  "owner_nickname": null,
  "share_count": null
}
Info

Re-POSTing the same slug is idempotent: the server upserts the existing activity and still returns 201 Created rather than 409 Conflict. The X-Resource-Action response header distinguishes the two cases:

  • X-Resource-Action: created — a fresh row was inserted
  • X-Resource-Action: updated — an existing slug was refreshed (name / priority / TTLs)

This lets integration bridges retry safely on network errors.

List Activities

GET /activities

List activities for the current user, including activities shared with you. Returns a cursor-paginated envelope sorted by slug.

Query Parameters

ParameterTypeDefaultDescription
limitinteger50Page size, between 1 and 100.
afterstringOpaque base64url cursor returned as next_cursor on the previous page. Omit for the first page.
statestringFilter 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,
      "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",
      "share_role": null,
      "owner_id": null,
      "owner_nickname": null,
      "share_count": null
    },
    {
      "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,
      "delete_at": null,
      "created_at": "2025-06-15T10:30:00Z",
      "updated_at": "2025-06-15T11:00:00Z",
      "ended_at": null,
      "share_role": null,
      "owner_id": null,
      "owner_nickname": null,
      "share_count": null
    },
    {
      "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
      "kind": "shared",
      "slug": "oven-timer",
      "name": "Oven Timer",
      "state": "ongoing",
      "priority": 0,
      "content": {},
      "ended_ttl": null,
      "stale_ttl": null,
      "delete_at": null,
      "created_at": "2025-06-15T09:00:00Z",
      "updated_at": "2025-06-15T10:00:00Z",
      "ended_at": null,
      "share_role": "viewer",
      "owner_id": "usr_abc123",
      "owner_nickname": "Bob",
      "share_count": null
    }
  ],
  "next_cursor": "eyJzIjoib3Zlbi10aW1lciIsImkiOjE5fQ"
}
Info

Iterate by passing next_cursor as the after parameter on the next request, until next_cursor is null or empty (the last page). The cursor encodes a stable (slug, id) pair, so paging stays consistent even if items are added or removed between requests.

Treat next_cursor as opaque — don't parse or construct it. Pass it back unchanged, and keep limit (and any future filter params) identical across pages of the same traversal.

# 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

Info

If the activity is still ongoing at the time of DELETE, the Live Activity is force-dismissed from the Lock Screen and Dynamic Island immediately — the server sends a push-end with a dismissal date in the past, so iOS removes it as soon as the call completes. This is independent of ended_ttl (which only applies to natural ongoing → ended transitions).

Deleting an activity that has already transitioned to ended only cleans up the database row. The on-screen lifetime is already governed by the dismissal date that was sent when the activity ended, so subsequent DELETEs 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.

Info

This is the main endpoint for integrations. Absent fields are preserved; explicit null clears the field; present values overwrite. Server-owned fields (warning_pushed, snoozed_until) are stripped from incoming patches.

Request Body

FieldTypeRequiredDescription
statestringNo"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.
contentobjectNoPartial template content. Fields present overwrite stored values; fields set to null clear them; absent fields are preserved. See Live Activities.
priorityintegerNoUpdate 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.
soundstringNoOptional 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.
Info

Merge-patch rules (per RFC 7396):

  • 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.
  • Semantic validation failures (e.g. arming alarm: true without end_date) return 422 Unprocessable Entity; 400 is reserved for malformed JSON or wrong shape.

State Transitions

FromToPush Action
endedongoingPush-to-start (starts Live Activity)
ongoingongoingPush update (updates running activity)
ongoingendedPush 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"
    }
  }'
Example: Update with an alert sound
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",
    "sound": "chime",
    "content": {
      "template": "generic",
      "progress": 1.0,
      "state": "Done",
      "icon": "washer",
      "accent_color": "green"
    }
  }'

Response (200):

The response is the unified Activity object — the same shape returned by GET, list, and POST. Sharing fields are always present (and null for activities the caller owns and hasn't shared).

{
  "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,
  "delete_at": null,
  "created_at": "2025-06-15T10:30:00Z",
  "updated_at": "2025-06-15T11:00:00Z",
  "ended_at": null,
  "share_role": null,
  "owner_id": null,
  "owner_nickname": null,
  "share_count": null
}

Content Object

The content object is shared across all templates. Only template is required; each template adds its own required fields on top.

FieldTypeDescription
templatestringRequired. One of: generic, countdown, steps, alert, gauge, timeline
progressfloatOptional. Value between 0.0 and 1.0. Computed automatically for gauge from value/min_value/max_value.
alarmbooleanOptional. Opt-in iOS AlarmKit alarm at end_date. Rings through silent mode and Focus. Requires end_date; iOS 26+ only. See Alarms.
snooze_secondsintegerOptional. How many seconds POST /activities/{slug}/snooze extends end_date, and the iOS AlarmKit snooze window. 603600; defaults to 300 (5 min) when omitted. Only meaningful with alarm: true.
snoozed_untilintegerRead-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.

See the Live Activities section for template-specific fields.

Tap actions

Every template accepts up to three optional tap targets on its content:

FieldWhere the tap target appears
tap_actionThe Live Activity background — tapping anywhere outside a button.
url_actionThe primary button (replaces the legacy url string).
secondary_url_actionThe secondary button (replaces the legacy secondary_url string).

All three accept the same Action object:

FieldTypeDescription
urlstringRequired. 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.
foregroundbooleanhttp(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.
methodstringHTTP method for a silent webhook. One of GET, POST, PUT, PATCH, DELETE, HEAD. Defaults to GET. Ignored for custom schemes.
headersobject<string,string>HTTP headers for a silent webhook. ≤1,024 bytes total across keys and values. Ignored for custom schemes.
bodystringHTTP request body for a silent webhook. ≤1,024 chars. Ignored for custom schemes.
titlestringButton label. Max 64 chars. Only meaningful on url_action / secondary_url_action — ignored on tap_action.
iconstringSF Symbol name (e.g. checkmark.circle). Max 64 chars. Only meaningful on url_action / secondary_url_action.

Dispatch modes

iOS decides what to do with the tap by inspecting the action object:

  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.

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

Activities support optional server-side time-to-live (TTL) for automatic lifecycle management:

FieldSet OnBehavior
stale_ttlCreateIf an ongoing activity receives no updates for this many seconds, the server auto-ends it
ended_ttlCreateWhen an activity transitions to ended, the server schedules auto-deletion after this many seconds and 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.
delete_atAutoComputed timestamp (read-only). Set automatically from ended_ttl when state becomes ended
ended_atAutoServer-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.
Info

When stale_ttl expires, the server auto-ends the activity with a distinct visual state: it sets content.state to "Stale (auto-ended)", accent_color to #8E8E93 (system gray), and icon to clock.badge.xmark. The activity then transitions to ended and a push-end notification is sent to all subscribed devices. If ended_ttl is also set, the auto-delete timer starts from that point.

The same expiration is sent to iOS as the APNs stale-date, so the Lock Screen presentation is marked stale at the same moment the server auto-ends the activity.

💡 Tip

Use ended_ttl to let the server clean up finished activities automatically and remove the Live Activity from the iOS lock screen at the same moment — the value is sent to APNs as dismissal-date. Integration bridges typically set this on creation so activities don't pile up either in the database or on the user's lock screen. Apple caps lock-screen dismissal at 4 hours; larger values still control row deletion but the on-device dismissal happens at 4h.

Alarms (iOS 26+ AlarmKit)

Setting content.alarm: true schedules a native iOS alarm at content.end_date using Apple's AlarmKit framework. The alarm rings like a Clock app alarm — breaking through silent mode and Focus — in addition to the Live Activity countdown.

Info

iOS 26 or later. On older iOS versions the field is silently ignored. 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. 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 (603600) 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}}'
💡 Tip

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 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 25).",
  "instance": "/activities",
  "code": "activity.limit_exceeded",
  "retry_after_ms": 3000,
  "errors": [
    { "message": "...", "location": "..." }
  ]
}
💡 Tip

Prefer matching on the stable code field over the human-readable detail string — detail messages may be reworded between releases, but code is contract-level. errors is an array of per-field messages, populated for shape-validation failures.

Status Codes

StatusMeaning
400Malformed JSON or wrong shape. Semantic validation now uses 422.
401Missing or invalid token
403Integration key not allowed for this activity, or insufficient scope
404Activity not found
409Conflict — e.g. activity limit reached or demo cooldown active. Pairs with a Retry-After response header and a retry_after_ms extension.
422Unprocessable: semantic validation failure (e.g. arming alarm: true without end_date, or POST /notifications referencing an unknown activity_slug).
429Rate limit exceeded. Pairs with a Retry-After response header and a retry_after_ms extension.

Known code Values

CodeStatusDescription
activity.limit_exceeded409Per-user activity cap reached on POST /activities.
demo.conflict409Demo flow is on cooldown — retry after retry_after_ms.
subscription.required403Endpoint requires an active subscription.
rate_limit.exceeded429IP rate limit hit — back off using Retry-After.