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.
This section describes a feature in an app update that's pending App Store review. It unlocks here automatically once the update is live.
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.
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. Replaced wholesale on PATCH -- re-send the full list (why); 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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Coming in app version 1.14.0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
compact_label | string | Optional, max 4 characters. Replaces the icon in the Dynamic Island compact leading ear. The minimal presentation ignores it. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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 |
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 |
Two ways the answer comes back
Taps follow the standard tap rules -- 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 aPATCH(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 writesanswer, 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:
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:
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"
}'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": "[email protected]" },
{ "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:
{
"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":
{ "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