Webhooks
Delivery and engagement events, POSTed to your endpoint and signed so you can prove they came from us.
Setup
Console → your app → Settings → Webhooks → add an HTTPS endpoint. Pick the events you want (or none, which means all). The signing secret is shown once — store it like a password; it's sealed at rest and we can't show it again.
HTTPS only. Private hostnames and raw IP addresses are rejected — that guard
stops a webhook being used to probe internal networks.
Events
| Event | Fires when |
|---|---|
message.sent | A device accepted the push (the provider returned success). |
message.failed | Delivery failed — error_code carries the normalized reason. |
message.clicked | A recipient opened the notification (all platforms). |
Payload
POST https://api.yourco.com/hooks/notibase
content-type: application/json
notibase-event: message.sent
notibase-signature: t=1787260000,v1=5f2b…c91
{
"event": "message.sent",
"created_at": "2026-08-20T18:04:11.238Z",
"data": {
"message_id": "9f1c…",
"device_id": "2b70…",
"channel": "webpush",
"error_code": null
}
}
Verifying the signature
The signature is HMAC-SHA256 over {timestamp}.{raw body}. Verify against the
raw request body — parsing and re-serializing changes the bytes and the check
will fail.
import crypto from "node:crypto";
export function verify(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(
header.split(",").map((kv) => {
const i = kv.indexOf("=");
return [kv.slice(0, i), kv.slice(i + 1)];
})
);
const ts = Number(parts.t);
if (!Number.isFinite(ts)) return false;
// Reject replays of an old, previously-valid request.
if (Math.abs(Math.floor(Date.now() / 1000) - ts) > toleranceSec) return false;
const expected = crypto.createHmac("sha256", secret)
.update(`${ts}.${rawBody}`).digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b); // constant-time
}
Express, keeping the raw body:
app.post("/hooks/notibase",
express.raw({ type: "application/json" }),
(req, res) => {
if (!verify(req.body.toString(), req.get("notibase-signature"), process.env.NB_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const evt = JSON.parse(req.body.toString());
// …do your work asynchronously, then:
res.sendStatus(200);
});
Delivery behaviour
- Return 2xx to acknowledge. Anything else counts as a failure.
- Respond fast — requests time out after 5 seconds. Queue the work, don't do it inline.
- Delivery is fire-and-forget from the send path: a slow or dead endpoint of yours can never delay or fail a customer's notification.
- After 15 consecutive failures the webhook disables itself and stops being called. Fix the endpoint, then hit re-enable in the console.
- The last 50 attempts per webhook — status code, error, duration — are visible under log in the console. That's the first place to look when "it isn't firing".
Treat events as at-least-once and possibly out of order. Make your
handler idempotent — key on
message_id + device_id + event.Troubleshooting
| Symptom | Cause |
|---|---|
| Signature never verifies | You're hashing a re-serialized body. Use the raw bytes. |
Hook went disabled | 15 straight failures. Check the log for the status code, fix, re-enable. |
| No attempts logged at all | The event isn't in the hook's subscription list, or the app has sent nothing since it was created. |
| Timeouts in the log | Your handler is doing work inline. Acknowledge first, process after. |