Webhooks
Register a URL per app and the worker POSTs you batches of events, signed with that endpoint's own secret. Manage endpoints in Dashboard › Webhooks.
Events
notification.sent · delivered · opened · clicked · dismissed · failed · skipped
subscription.invalidated
campaign.completed
notification.* map 1:1 onto the analytics events. subscription.invalidated fires when a transport reports a dead token. campaign.completed fires when fan-out finishes — every push is queued, not necessarily sent.
Batching
At fan-out speed a POST per event would be thousands of requests a second at your server. Both the API (tracking pings) and the worker (send results) buffer events per endpoint for up to 1 second or 100 events, whichever first, then enqueue one delivery job. The worker makes one POST per batch.
Payload
POST https://hooks.acme.example/opennotification
content-type: application/json
x-opennotification-signature: t=1758000000,v1=5f1a…
{
"id": "dlv_3kJ9…",
"events": [
{
"id": "evt_8sQ2…",
"type": "notification.delivered",
"ts": "2026-09-17T12:34:56.789Z",
"appId": "66f1…",
"campaignId": "66f2…",
"userId": "66f3…",
"externalId": "user_123",
"subscriptionId": "66f4…",
"platform": "ios"
},
{
"id": "evt_9tR3…",
"type": "notification.failed",
"ts": "…", "appId": "…", "campaignId": "…", "userId": "…", "subscriptionId": "…", "platform": "android",
"data": { "code": "UNREGISTERED" }
},
{
"id": "evt_0uS4…",
"type": "campaign.completed",
"ts": "…", "appId": "…", "campaignId": "…",
"data": { "queued": 1650, "byPlatform": { "ios": 700, "android": 690, "web": 260 }, "name": "Weekend flash sale", "transactional": false }
}
]
}
data carries a failure code or campaign stats — never a token or endpoint. externalId is present when the user has one.
Verifying the signature
The header is t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>. Verify with the raw request body, before parsing JSON, and reject timestamps older than 5 minutes.
// Bun / Node
import { verifyWebhookSignature } from "@opennotification/core";
app.post("/opennotification", async (req) => {
const body = await req.text();
const check = verifyWebhookSignature(
process.env.WHSEC!,
body,
req.headers.get("x-opennotification-signature") ?? "",
{ toleranceSeconds: 300 },
);
if (!check.ok) return new Response(check.reason, { status: 401 }); // malformed | bad_signature | too_old
const { events } = JSON.parse(body);
// …
return new Response(null, { status: 204 });
});
Without the package:
import hmac, hashlib, time
def verify(secret: str, header: str, body: bytes, tolerance=300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = parts["t"], parts["v1"]
if abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
Responses and retries
| Your response | Worker does |
|---|---|
2xx | Done. |
5xx, 429, 408, network error, timeout | Retry with exponential backoff starting at 30 s, 5 attempts. |
any other 4xx | Your bug; not retried. |
Respond fast (204 and process asynchronously). Batches are at-least-once: keep event.id idempotent on your side.
Auto-disable
After 50 consecutive batches exhaust their retries, the endpoint is switched off (disabledReason on the row, auto-disabled in the panel) so a dead URL does not burn retries forever. Fix the receiver and press Enable; the counter resets.
Restrictions
- HTTPS URLs only.
localhost,127.*and169.254.169.254are refused (SSRF guard). - Secrets (
whsec_…) are shown once, stored sealed, and can be rotated from the panel — the old one stops working immediately. - Test from the panel: a signed batch with a single
{ "type": "ping" }event, sent synchronously; the response status is shown.