Onay Şablonu
2-4 yanıt düğmeli bir soru kartı: düğmeler webhook'unuzu tetikler ya da yanıtı sunucu tarafında kaydeder — ajanlar, dağıtım onayları ve ev otomasyonları için tasarlandı.

On Android, an approval is a Live Update in the notification shade: the question, its details and source, with the options as buttons under it. Android shows at most three buttons, so a fourth option is left off there. An option answers in the background, or opens its app when its url is a custom scheme. The status bar chip counts down to end_date when one is set, shows a question mark while the question is open and a check mark once it is answered.
In this capture a production deploy waits for Deploy or Skip.
Bu bölüm, App Store incelemesini bekleyen bir uygulama güncellemesindeki bir özelliği açıklar. Güncelleme yayınlanır yayınlanmaz burada otomatik olarak açılır.
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). A changed set clears the stored answer and starts a new round, and so does a changed step; an identical re-send on the same step keeps the answer. That is how one activity carries several 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 starts a new round (a changed option set or a changed step). 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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
step | integer | Which question of a card with several questions this is, counting from 1. Requires step_count and must not exceed it. A changed step starts a new round even when the options are the same | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
step_count | integer | How many questions the card walks through, 1-10. Requires step. Drawn as a 2/3 pill; changing it alone starts no new round | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 1.14.0 uygulama sürümünde geliyor | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
compact_label | string | Optional, max 4 characters. Replaces the icon in the Dynamic Island compact leading ear. The minimal presentation ignores it. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 1.19.1 uygulama sürümünde geliyor | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
compact_trailing_label | string | Optional, max 4 characters. Replaces the deadline countdown or the status glyph in the Dynamic Island compact trailing ear, and takes precedence over compact_trailing_icon when both are set. The minimal presentation ignores it. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
compact_trailing_icon | string | Optional, max 128 characters. SF Symbol name or MDI icon with mdi: prefix, drawn in place of the deadline countdown or the status glyph in the Dynamic Island compact trailing 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, unless the card has another question to ask. 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?wait=25" \
-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" }
}
}Read GET /activities/{slug} until content.answer is set. With ?wait=N (up to 25 seconds) the request is held open while the approval waits for its answer and returns as soon as it is answered or ends; when the time runs out it returns the activity unchanged, so repeat it to wait longer. Without wait, a few seconds between requests is plenty. Too many waits open at once return 429 with code answer_wait.limit_exceeded. Agents on the MCP server can call wait_for_answer with the slug instead. 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" } }Several questions on one card
One approval card can ask up to ten questions in turn, the way a release checklist or a coding agent asks them. Send step and step_count with the first question, wait for its answer, then PATCH the next question onto the same card with step one higher. The card stays on the Lock Screen between questions, with its place shown as 2/3.
stepandstep_countcome together or not at all, with1 <= step <= step_count <= 10. Anything else is a422.- A changed
stepstarts a new answer round: the storedansweris cleared and server-recorded options get new answer URLs, even when they are the same Yes and No as last time. Changingstep_countalone does not. - An answer on a step before the last is pushed to every device and returned to a waiting
GET ?wait=, but the card staysongoingfor the next question. The answer on the last step ends the card a few seconds later, as on a card with one question. - An answered step that never gets its next question is ended 10 minutes after the answer, or at
end_dateif that comes first. It ends with that answer. end_dateandon_expireare one deadline for the whole card. When it passes on an open question the default is recorded and the card ends, on whatever step it is.- iOS cannot change the activity
namein place, so give the card a name that fits every question ("Release 2.4", "Claude Code"). What changes per step goes instate,details,optionsandicon. - On iPhone the server puts
2/3in the trailing ear of the compact Dynamic Island, unless you setcompact_trailing_labelorcompact_trailing_iconyourself.
A release that asks three things, server-recorded, so there is nothing to host. Create the card, then ask the first question:
curl -X POST https://api.pushward.app/activities \
-H "Authorization: Bearer hlk_YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"slug": "release-2-4",
"name": "Release 2.4"
}'curl -X PATCH https://api.pushward.app/activities/release-2-4 \
-H "Authorization: Bearer hlk_YOUR_TOKEN" \
-H "Content-Type: application/merge-patch+json" \
-d '{
"state": "ongoing",
"content": {
"template": "approval",
"state": "Run the database migrations now?",
"source": "Forgejo CI",
"icon": "shippingbox.fill",
"step": 1,
"step_count": 3,
"options": [
{ "id": "yes", "title": "Yes", "style": "primary" },
{ "id": "no", "title": "No", "style": "secondary" }
]
}
}'curl "https://api.pushward.app/activities/release-2-4?wait=25" \
-H "Authorization: Bearer hlk_YOUR_TOKEN"
{
"state": "ongoing",
"content": {
"template": "approval",
"state": "Run the database migrations now?",
"step": 1,
"step_count": 3,
...
"answer": { "option": "yes", "at": 1788220800, "by": "user" }
}
}Repeat the wait until content.answer is set. The card is still ongoing: it shows the answer and "Next question..." until you send the next one. Check that content.step is the step you asked before acting on the answer, then patch the next question in. The options here are the same as before; the new step is what starts a new round:
curl -X PATCH https://api.pushward.app/activities/release-2-4 \
-H "Authorization: Bearer hlk_YOUR_TOKEN" \
-H "Content-Type: application/merge-patch+json" \
-d '{
"content": {
"state": "Tag 2.4.0 and push the tag?",
"step": 2,
"step_count": 3,
"options": [
{ "id": "yes", "title": "Yes", "style": "primary" },
{ "id": "no", "title": "No", "style": "secondary" }
]
}
}'Wait the same way, then ask the last question. Its answer is the card's outcome, and the card ends on its own a few seconds after it:
curl -X PATCH https://api.pushward.app/activities/release-2-4 \
-H "Authorization: Bearer hlk_YOUR_TOKEN" \
-H "Content-Type: application/merge-patch+json" \
-d '{
"content": {
"state": "Publish the release notes?",
"step": 3,
"step_count": 3,
"options": [
{ "id": "now", "title": "Now", "style": "primary", "icon": "checkmark.circle" },
{ "id": "draft", "title": "As draft", "icon": "doc.text" },
{ "id": "skip", "title": "Skip", "style": "destructive", "icon": "xmark.circle" }
]
}
}'PushWard 1.20.0 on iPhone and Mac and 1.1.0 (build 17) on Android show where the card is: a 2/3 pill on the card and in the expanded Dynamic Island, the position in the compact trailing ear until the last answer, and "Question 2 of 3" at the head of the Android notification, whose status bar chip shows the position while the card waits for the next question. Older versions show each question in turn, with working buttons, but without the pill or the Android line. On iPhone, 1.19.1 and later still show 2/3 in the compact trailing ear, which the server fills in for them.
Android versions before 1.1.0 (build 17) keep a tapped button in its sent state when the next step arrives with the same options as the one before, and refuse the next tap. Server-recorded options are not affected: they get new answer URLs every step. Options that call your own url must differ from step to step, for example by carrying the step in the url or the body.
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