確認が必要なアラート
誰かがいずれかのデバイスで確認するまで繰り返し届くアラートを送信します。確認した人と時刻を読み取ることも、PushWard からエンドポイントを呼び出させることもできます。
このセクションでは、App Store の審査待ちのアプリアップデートに含まれる機能について説明します。アップデートが公開されると、ここで自動的に利用できるようになります。
Add acknowledge to a POST /notifications body and PushWard sends the notification again every repeat_seconds, until someone acknowledges it on one of their devices, until expire_seconds have passed, or until you cancel it. It is
for the alerts where one buzz in a pocket is not enough: a water leak, a server that stopped answering, the
garage door open at midnight.
curl -X POST https://api.pushward.app/notifications \
-H "Authorization: Bearer hlk_YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "Water leak",
"body": "Sensor under the kitchen sink",
"level": "time-sensitive",
"source": "home",
"acknowledge": { "repeat_seconds": 120, "expire_seconds": 3600 },
"tags": ["leak"],
"callback_url": "https://hooks.example.com/pushward"
}'This one has no button of its own that PushWard records, so the server adds an Acknowledge button. The response shows it, and carries the notification's receipt:
{
"id": 4242,
"title": "Water leak",
"body": "Sensor under the kitchen sink",
"level": "time-sensitive",
"source": "home",
"actions": [
{ "id": "pw_ack", "title": "Acknowledge", "icon": "checkmark.circle" }
],
"answerable": true,
"pushed": true,
"created_at": "2026-10-20T07:00:00Z",
"delivery": "all",
"receipt": {
"notification_id": 4242,
"status": "active",
"repeat_seconds": 120,
"expires_at": "2026-10-20T08:00:00Z",
"repeats_sent": 0,
"last_delivered_at": "2026-10-20T07:00:00Z",
"tags": ["leak"],
"callback": { "status": "pending", "attempts": 0 },
"created_at": "2026-10-20T07:00:00Z"
}
}The acknowledge object
| Field | Default | Range |
|---|---|---|
repeat_seconds | 60 | 30 to 3600. Seconds between repeats. |
expire_seconds | 3600 | 60 to 10800. After this the repeats stop and the receipt is expired. |
action_title | Acknowledge | 1 to 64 characters. The label of the button the server adds. |
"acknowledge": {} takes all three defaults: once a minute, for an hour.
- A repeat is the same notification again, with the same text, level, actions and media. Each repeat alerts
again but replaces the previous one in Notification Center, so they do not stack up. Set
leveltotime-sensitiveif it has to get through a Focus. - At most 50 repeats are sent. Every 30 seconds for 3 hours would be 360, so a short
repeat_secondswith a longexpire_secondsgoes quiet after the 50th and waits out the rest of its time. - Repeats are free. The first send counts toward your notification quota like any other notification; the repeats do not.
- Each repeat goes to the devices the account has at that moment, so a phone signed in halfway through gets the next one. For an organization, repeats go to the members who got the first send, with their current mute and routing settings.
push: falseandlevel: passivecannot be acknowledged; both are a400.- Up to 25 acknowledged notifications can be active per account at once, and an organization's sends count
against the organization. One more is a
409withnotification_receipt.limit_exceeded. - A new acknowledged notification with the same
collapse_id, sent with the same integration key, replaces one that is still repeating: the old receipt is canceled withcancel_reasonsupersededbefore the 25 are counted. Useful for an alert that changes while it is open (a temperature still climbing). - Scheduled notifications take
acknowledge,tagsandcallback_urltoo, and start repeating when they go out. If 25 are already active at that moment, that send goes out once as a plain notification, without repeats or a receipt (GET /notifications/receipts/{id}answers 404 for it).
Which taps count
Any tap that PushWard records as an answer acknowledges the
notification: an action without a url and without foreground, including a typed
reply. When none of your actions is like that, the server puts its own button first: { "id": "pw_ack", "title": "<action_title>", "icon": "checkmark.circle" }.
A button that opens a URL or the app does not count, so this works:
"actions": [
{ "id": "fixing", "title": "On it", "icon": "wrench" },
{ "id": "dashboard", "title": "Open Grafana", "url": "https://grafana.example.com/d/water" }
],
"acknowledge": {}Here On it acknowledges and no pw_ack is added. The id pw_ack is
reserved for the server (400 if you use it), and ten actions with none of them answerable leave
no room for the extra button (400). The answer is also readable from GET /notifications/answers/{id} as usual.
Tags and callback_url
Both sit at the top level of the request, next to acknowledge, and both need it. tags is a list of up to 10 labels, each 1 to 64 printable ASCII characters without spaces, used
to cancel a group of alerts at once. Duplicates are dropped. callback_url is an https URL that gets a signed POST when the notification is acknowledged or expires; see Callbacks.
The receipt
The receipt's id is the notification id, the id from POST /notifications, or the notification_id of a sent scheduled notification. Fields that do not apply yet are left out.
| Field | Description |
|---|---|
notification_id | The notification this receipt belongs to. |
status | active (still repeating), acknowledged, expired (expire_seconds ran out first) or canceled. |
repeat_seconds | Seconds between repeats. |
expires_at | When it expires if nobody acknowledges it. |
repeats_sent | Repeats sent so far, not counting the first send. At most 50. |
last_delivered_at | When a push, the first or a repeat, last reached at least one device. |
acknowledged_at | When it was acknowledged. |
acknowledged_by | User id of who acknowledged it: the account, or for an organization the member who tapped, when PushWard can tell. |
acknowledged_by_device | The name (or else the model) of the device it was acknowledged on, when the app reported it. Personal sends only. |
action_id | The action that acknowledged it; pw_ack for the button the server adds. |
canceled_at | When it was canceled. |
cancel_reason | api (canceled by id), tag, superseded (a newer send with the same collapse_id replaced it), key_revoked (the sending key was revoked, expired or
lost notifications access) or org_disabled. |
tags | The tags it was sent with. |
callback | Present when the send had a callback_url: status (pending, delivered or failed), attempts, delivered_at and last_status_code. |
created_at | When it was sent. |
Finished receipts stay readable for 7 days, then they are deleted.
Reading and canceling
/notifications/receipts/{id}The receipt. ?wait=0-25 holds the request open while the receipt is active and returns as soon as it is acknowledged, expires or is canceled, or with status active when the time is up. 404 notification_receipt.not_found when the notification was not sent with acknowledge.
/notifications/receipts/{id}/cancelStops the repeats and returns the receipt. A receipt that already finished comes back unchanged.
/notifications/receipts/cancelCancels every active receipt sent with a tag. Body: {"tag": "leak"}. Returns {"canceled": <count>}.
curl "https://api.pushward.app/notifications/receipts/4242?wait=25" \
-H "Authorization: Bearer hlk_YOUR_TOKEN"{
"notification_id": 4242,
"status": "acknowledged",
"repeat_seconds": 120,
"expires_at": "2026-10-20T08:00:00Z",
"repeats_sent": 3,
"last_delivered_at": "2026-10-20T07:06:00Z",
"acknowledged_at": "2026-10-20T07:07:12Z",
"acknowledged_by": "7d1c5e2a-3f4b-4c8e-9a61-2b0f6d8e4c13",
"acknowledged_by_device": "Kitchen iPad",
"action_id": "pw_ack",
"tags": ["leak"],
"callback": { "status": "delivered", "attempts": 1, "delivered_at": "2026-10-20T07:07:13Z", "last_status_code": 200 },
"created_at": "2026-10-20T07:00:00Z"
}A long-poll that runs out is not an error: it returns the receipt, still active, and you ask
again. Open ?wait requests are shared with answer and activity reads, at most 4 per account; one
more is a 429 answer_wait.limit_exceeded.
Canceling
# one notification
curl -X POST https://api.pushward.app/notifications/receipts/4242/cancel \
-H "Authorization: Bearer hlk_YOUR_TOKEN"
# everything still repeating with the tag "leak"
curl -X POST https://api.pushward.app/notifications/receipts/cancel \
-H "Authorization: Bearer hlk_YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"tag": "leak"}'Canceling only stops the repeats; it is not a recall. Banners already delivered stay where they are, the notification stays in the app's history, and no callback is sent for a canceled receipt. Call it from the thing that fixed the problem: the leak sensor reading dry again, the health check passing.
An integration key reads and cancels only the receipts of notifications it sent itself. Another key's
notification id is a 404, and canceling by tag skips the other keys' alerts with the same tag.
Revoking a key, letting it expire or taking away its notifications access cancels its active
receipts with key_revoked.
Callbacks
With a callback_url, PushWard sends one POST when the receipt finishes: notification.acknowledged or notification.expired. A canceled receipt sends
nothing. Delivery is at least once, so the same event can arrive twice; deduplicate on webhook-id.
POST /pushward HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: PushWard-Webhook/1
webhook-id: pwr_4242
webhook-timestamp: 1792480033
webhook-signature: v1,qERR29JWI+VB49/fNoMAEHY3LArHdrYidBMh5V7HXWc=
{"type":"notification.acknowledged","timestamp":"2026-10-20T07:07:12Z","data":{"notification_id":4242,"status":"acknowledged","repeat_seconds":120,"expires_at":"2026-10-20T08:00:00Z","repeats_sent":3,"last_delivered_at":"2026-10-20T07:06:00Z","acknowledged_at":"2026-10-20T07:07:12Z","acknowledged_by":"7d1c5e2a-3f4b-4c8e-9a61-2b0f6d8e4c13","acknowledged_by_device":"Kitchen iPad","action_id":"pw_ack","tags":["leak"],"created_at":"2026-10-20T07:00:00Z"}}| Header | Value |
|---|---|
webhook-id | pwr_<notification_id>, the same on every retry. |
webhook-timestamp | Unix seconds of this attempt. |
webhook-signature | v1, and the base64 HMAC-SHA256 of webhook-id, a dot, webhook-timestamp, a dot and the raw body, keyed with the signing secret. |
In the body, type is the event, timestamp is when the receipt finished (the same on
every retry), and data is the receipt as GET /notifications/receipts/{id} returns it, without callback.
Delivery and retries
- Any 2xx counts as delivered. A
410fails it at once (the endpoint is gone). Any other status, a timeout or a connection error is retried 1, 2, 4, 8, 16 and 32 minutes later, and after that the callback isfailed. Redirects are not followed; a 3xx counts as a failed attempt. - Each attempt gets 10 seconds in all (5 to connect, 5 for TLS). PushWard reads at most 64 KB of your
response body and does nothing with it, so answer
204quickly and do the work afterwards. - The URL has to be https, with a host, no user name or password, and at most 2048 characters. It cannot
point at a private place:
localhost,.local,.internal,.svc,.cluster.localor a private IP address are refused when you send, and a name that resolves to a private, loopback, link-local or CGNAT address fails the callback when PushWard connects. A Home Assistant on your LAN is out of reach; use its Home Assistant Cloud webhook URL. - A revoked or expired key leaves nothing to sign with: its pending callbacks fail without a request.
Verifying the signature
There is no secret to create or rotate. It is derived from the integration key that sent the notification:
key_hash = SHA-256(integration key as UTF-8) # 32 raw bytes
secret = HMAC-SHA256(key = key_hash, message = "pushward/callback/v1")
whsec = "whsec_" + base64(secret)pushward receipt secret prints the whsec_ form for the CLI's configured key, or for
another one with --key -, which reads it from stdin and keeps it out of your shell history. The
scheme is Standard Webhooks, so any of its libraries
verifies these requests once you give it that whsec_ secret. Rolling the key changes the secret
for every callback delivered after the roll, including retries of events from before it.
op read op://Private/pushward-alerts/credential | pushward receipt secret --key -Without a library, verify the signature over the raw body, before parsing it, and reject timestamps more than five minutes off:
import base64, hashlib, hmac, time
def callback_secret(integration_key: str) -> bytes:
key_hash = hashlib.sha256(integration_key.encode()).digest()
return hmac.new(key_hash, b"pushward/callback/v1", hashlib.sha256).digest()
def verify(headers, body: bytes, integration_key: str, tolerance: int = 300) -> bool:
"""headers: the request headers (any mapping); body: the raw request body."""
msg_id = headers.get("webhook-id", "")
ts = headers.get("webhook-timestamp", "")
if not (ts.isascii() and ts.isdigit()) or abs(time.time() - int(ts)) > tolerance:
return False
signed = f"{msg_id}.{ts}.".encode() + body
mac = hmac.new(callback_secret(integration_key), signed, hashlib.sha256)
expected = base64.b64encode(mac.digest()).decode()
# The header can carry several space-separated signatures.
for sig in headers.get("webhook-signature", "").split():
version, _, value = sig.partition(",")
if version == "v1" and hmac.compare_digest(value, expected):
return True
return Falseimport { createHash, createHmac, timingSafeEqual } from 'node:crypto';
export function callbackSecret(integrationKey) {
const keyHash = createHash('sha256').update(integrationKey).digest();
return createHmac('sha256', keyHash).update('pushward/callback/v1').digest();
}
// headers: lower-case names, as node:http gives them. rawBody: the request
// body exactly as received (a Buffer), before any JSON parsing.
export function verify(headers, rawBody, integrationKey, toleranceSeconds = 300) {
const id = headers['webhook-id'] ?? '';
const ts = headers['webhook-timestamp'] ?? '';
if (!/^\d+$/.test(ts)) return false;
if (Math.abs(Date.now() / 1000 - Number(ts)) > toleranceSeconds) return false;
const expected = createHmac('sha256', callbackSecret(integrationKey))
.update(`${id}.${ts}.`)
.update(rawBody)
.digest();
// The header can carry several space-separated signatures.
return (headers['webhook-signature'] ?? '').split(' ').some((sig) => {
const [version, value = ''] = sig.split(',');
const got = Buffer.from(value, 'base64');
return version === 'v1' && got.length === expected.length && timingSafeEqual(got, expected);
});
}package callback
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"io"
"math"
"net/http"
"strconv"
"strings"
"time"
)
// Secret derives the signing secret from the integration key that sends.
func Secret(integrationKey string) []byte {
sum := sha256.Sum256([]byte(integrationKey))
mac := hmac.New(sha256.New, sum[:])
mac.Write([]byte("pushward/callback/v1"))
return mac.Sum(nil)
}
// Verify checks a callback request and returns its body.
func Verify(r *http.Request, integrationKey string) ([]byte, error) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
return nil, err
}
id := r.Header.Get("webhook-id")
ts := r.Header.Get("webhook-timestamp")
sec, err := strconv.ParseInt(ts, 10, 64)
if err != nil || math.Abs(time.Since(time.Unix(sec, 0)).Seconds()) > 300 {
return nil, errors.New("timestamp outside tolerance")
}
mac := hmac.New(sha256.New, Secret(integrationKey))
mac.Write([]byte(id + "." + ts + "."))
mac.Write(body)
want := mac.Sum(nil)
// The header may carry several space-separated signatures.
for _, sig := range strings.Fields(r.Header.Get("webhook-signature")) {
v, b64, ok := strings.Cut(sig, ",")
if !ok || v != "v1" {
continue
}
got, err := base64.StdEncoding.DecodeString(b64)
if err == nil && hmac.Equal(got, want) {
return body, nil
}
}
return nil, errors.New("signature mismatch")
}/e2e/callback-vectors-v1.json has
two integration keys with their derived secrets and signed sample requests to test your code against. The
request above is signed with the first of those keys.
From the CLI, MCP and integrations
Acknowledged alerts need CLI 1.4.0, pushward-mcp 1.16.0, the Home Assistant integration 0.51.0, the Grafana
plugin 0.10.0 or the Unraid plugin 2026.10.08 or later; on the Relay they need ?ack=1.
The app shows them on every version; see Older app versions for what 1.17.0 adds.
The CLI has --ack on notify and schedule create, with --ack-repeat (30s to 1h), --ack-expire (1m to 3h)
and --ack-title, which imply it, plus --tag (repeatable) and --callback-url. notify --ack --wait blocks until it is acknowledged. The GitHub Action
takes the same as inputs: ack, ack-repeat, ack-expire, ack-title, tags and callback-url.
pushward notify --title "db-1 down" --body "Primary unreachable" \
--level time-sensitive --ack --ack-repeat 2m --tag db-1
pushward receipt 4242 --wait 10m # exit 7 if it expires, or 10m pass, first
pushward receipt cancel --tag db-1 # db-1 is back: stop repeatingThe MCP server passes acknowledge, tags and callback_url through create_notification and create_scheduled_notification, and adds get_notification_receipt, wait_for_ack (waits up to 10 minutes and reports how it ended), cancel_notification_receipt and cancel_notification_receipts_by_tag. Give acknowledge at least one field there, such as {"repeat_seconds": 60}: the
tools drop an empty object, and tags or callback_url without it are refused.
In Home Assistant, pushward.send_notification takes acknowledge (true for the defaults, or the object), tags and callback_url, and returns the receipt when you ask for a response. pushward.cancel_notifications stops one by notification_id or all of them with a tag.
The Grafana app plugin has a Repeat until acknowledged switch under Also send a push notification, with the repeat interval and the expiry next to it (300 and 3600 seconds by default). The firing push repeats, and the resolve stops the repeats before the resolved push goes out.
The Unraid plugin has the same setting under Settings → PushWard. Only notifications that arrive as Unraid alerts repeat, tagged unraid-<server name>, and a later notice for the same event that is not an alert stops
them.
On the Relay, add ack=1 to the webhook URL, with ack_repeat and ack_expire for the timing. Alerts from Grafana, Uptime Kuma, Gatus,
Komodo and the universal webhook then repeat until acknowledged, and the resolve webhook stops them where the service sends one. TrueNAS
cannot send query parameters, so its API URL is https://relay.pushward.app/truenas/ack instead.
The Grafana plugin, the Unraid plugin and the relay all send the alert once without repeats, rather than lose it, when the account already has 25 active or the server refuses the acknowledge.
Older app versions
The repeats and the Acknowledge button work on every version of the app, because the button
is an ordinary action that PushWard records. PushWard 1.17.0 adds two things. When someone acknowledges, the
notification is cleared from every iPhone, iPad and Mac that got it; older versions leave it there until it
is dismissed by hand. And only 1.17.0 reports which device the tap came from, so acknowledged_by_device stays empty for taps on older versions.
Errors
| Status | Code | When |
|---|---|---|
400 | notification.invalid | acknowledge with push: false or level: passive; a value out of
range; your own action with id pw_ack; ten actions and none answerable; tags or callback_url without acknowledge; a bad tag or a callback_url that is not an allowed https URL; a callback_url sent without an
integration key (its signing secret comes from the key); text so long that the answer links push the
notification past 4 KB. |
404 | notification_receipt.not_found | The notification was not sent with acknowledge, was sent by another key, or finished more than 7 days ago. |
409 | notification_receipt.limit_exceeded | 25 acknowledged notifications are already active. Wait for one to finish, or cancel one. |
422 | notification.encrypted_too_large | An encrypted acknowledged notification whose largest repeat would not fit 4 KB. |
422 | notification_receipt.disabled | Acknowledged notifications are switched off for a while on the server. Send without acknowledge, or retry later. A schedule that fires meanwhile goes out as a plain
notification, with no receipt. |
429 | answer_wait.limit_exceeded | Too many ?wait requests open for the account. |
Quotas in short: the first send is one notification, repeats are free, 25 active per account, 50 repeats each. All the numbers are on Limits.