Actividades
Crea, actualiza y finaliza las actividades detrás de cada actividad en directo con simples llamadas REST: POST para iniciar, PATCH para actualizar, DELETE para finalizar.
Each user can have a maximum of 50 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
/activitiesCreate 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 |
target | object | No | Organization keys only: who in the organization gets it, as groups, tags and members. Re-POSTing an existing slug without it keeps the stored target. See Teams. |
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
}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
/activitiesList 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. |
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"
}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
/activities/{slug}Get a single activity by slug. For an approval activity still waiting for its answer, ?wait=N (up to 25 seconds) holds the request open until it is answered or ends (long-poll), and returns it unchanged when the time runs out. Other activities ignore wait.
curl https://api.pushward.app/activities/dishwasher \
-H "Authorization: Bearer hlk_YOUR_TOKEN"Delete Activity
/activities/{slug}Delete an activity and all associated subscriptions.
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)
/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. |
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. |
target | object or null | No | Organization keys only: an object replaces who gets the activity, null sends it back to everyone the routing rules allow, and leaving it out keeps it. Devices that lose it end it; devices that gain it start it. See Teams. |
Merge-patch semantics
Updates follow RFC 7396 JSON Merge Patch: 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,
alarmandwarning_thresholdpersist across updates until explicitly cleared withnull. - If both
end_dateanddurationare sent,end_datewins.durationaccepts integer seconds (60) or a string ("60s","5m","1h30m"). - Transitioning to
endedclearsalarm,snoozed_until, andwarning_pushedon 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. |
An upsert-created slug returns 201 Created with X-Resource-Action: created; an existing slug behaves like a normal patch (200 OK). Creation requires activities at manage (permissions) — a key at update 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.
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) |
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.
Content Object
Shared fields on content; each template adds its own required fields on top (see 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. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
lines | array | Required for the log template. 1–20 line objects, newest first. See 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. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Llega en la versión 1.9.0 de la app | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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 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. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Llega en la versión 1.11.0 de la app | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Llega en la versión 1.14.0 de la app | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
compact_label | string | Optional, max 4 characters. Replaces whatever the template draws in the Dynamic Island compact leading ear: the icon, the artwork, the steps ring, the timeline high/low stack. Applies to every template. The minimal presentation ignores it. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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<string,string> | 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:
- Custom scheme (e.g.
homeassistant://,youtube://) → callsopenURLto launch the target app.foreground,method,headers, andbodyare ignored. http(s)withforeground: true→ opens the URL in Safari / the in-app browser. The app is brought to the foreground.http(s)with at least one ofmethod/headers/body(andforegroundabsent orfalse) → 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.- 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 absentforegroundkey is interpreted as "open this".
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:
{
"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 to14400) — the card lingersNseconds after ending, independent of when the database row is deleted.- Unset
dismissal_ttl, withended_ttl— legacy coupling:ended_ttldrives both the server-side auto-delete and the on-device dismissal date (capped at 4 hours). The minimum is1;0is rejected with422. - 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 ifdismissal_ttlis0. 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 apply. Sending it together with the end transition applies it to that end:
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}'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 aPATCHbody, or by transitioning toended. - Requires
end_date. Sendingalarm: truewithoutcontent.end_datereturns422 Unprocessable Entity. - Updating
end_datereschedules the alarm. Send a new PATCH with the newend_date; the armed alarm re-derives automatically. - Past
end_dateis a no-op. If the push arrives afterend_datehas 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 onlycountdownmeaningfully uses it. - What the user sees. When the alarm fires, iOS presents a full-screen alarm using the activity's
nameas the title with Dismiss and Snooze buttons. Snooze extendsend_dateby the configured snooze window and setssnoozed_untilso 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 to300(5 min) when omitted. iOS reads this value to size its AlarmKit snooze countdown, so a changed value applies to the next scheduled alarm.
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.
# 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 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": "..." }
]
}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 its activities level is too low, or an organization key limited to some groups and tags sending outside them |
404 | Activity not found, also for an activity outside the groups and tags of a limited organization key |
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, POST /notifications referencing an unknown activity_slug, or a target that names nothing or an unknown group). |
429 | Distinguish the cases via the code field (rate_limit.exceeded, quota.exceeded, or answer_wait.limit_exceeded on a long-poll read). See the code table below 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 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. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
integration_key.target_denied | 403 | An organization key limited to some groups and tags sent outside them, cleared a target, or tried to create a slug an activity outside them already holds. See Teams. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
org.target_unavailable | 422 | A target sent with a personal key; only organization keys can target. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
org.target_invalid | 422 | The target names nothing or a member by something other than a user id, or (on PATCH) more than 20 groups, 20 tags or 100 members. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
org.target_unknown | 422 | The target names groups, tags or members the organization does not have; detail lists them. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
rate_limit.exceeded | 429 | IP rate limit hit — back off using Retry-After. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
answer_wait.limit_exceeded | 429 | Too many long-poll reads (?wait= on GET /activities/{slug} or GET /notifications/answers/{id}) open at once for the account. Retry shortly, or read without wait. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Llega en la versión 1.17.0 de la app | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
notification.encrypted_too_large | 422 | An end-to-end encrypted notification whose push, with its other fields, would exceed 4 KB. See Encryption limits. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
notification.encryption_unavailable | 422 | encrypted sent with an organization key; organizations cannot send encrypted notifications yet. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
notification_receipt.limit_exceeded | 409 | 25 acknowledged notifications are already active for the account (or organization). See Acknowledged Alerts. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
notification_receipt.not_found | 404 | No receipt for that id: the notification was not sent with acknowledge, was sent by another key, or finished more than 7 days ago. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
notification_receipt.disabled | 422 | Acknowledged notifications are switched off on the server for now. Send without acknowledge or retry later. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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"
}