Create endpoints under Project → Webhooks → Event webhooks. Each endpoint subscribes to the events you choose and receives a signed POST when they occur. For inbound provider events (SendGrid/Twilio) see channel setup instead — this page is the outbound stream.
Note
{{ externalId }}, {{ event.x }}, {{ eventName }}, and {{ tag.x }} tokens.Event types
message.sent— accepted by the providermessage.delivered— reached the device/inboxmessage.opened/message.clicked— engagementmessage.bounced— hard bounce / invalid recipientmessage.failed— delivery failedmessage.unsubscribed— user opted out
Payload
The body is JSON. id is stable across retries — use it as an idempotency key so you process each event once.
{
"id": "evt_2a7f…",
"type": "message.delivered",
"timestamp": "2026-07-04T10:45:00Z",
"data": {
"messageId": "…",
"channel": "PUSH",
"externalId": "user-123"
}
}Verifying the signature
Each request carries three headers:
| Header | Meaning |
|---|---|
X-Payghaam-Id | Event id (idempotency key) |
X-Payghaam-Timestamp | Unix seconds of this attempt |
X-Payghaam-Signature | Space-separated v0,<base64> signatures |
The signature is HMAC-SHA256 of `${id}.${timestamp}.${body}` keyed with your endpoint secret, base64-encoded. Compute it over the raw request body — re-serializing the JSON can change bytes and break the match.
import crypto from "node:crypto";
// secret = the "whsec_…" shown once when you created the endpoint
export function verify(req, secret) {
const id = req.headers["x-payghaam-id"];
const ts = req.headers["x-payghaam-timestamp"];
const sigHeader = req.headers["x-payghaam-signature"]; // "v0,<b64> v0,<b64>"
const body = req.rawBody; // the exact bytes, not a re-serialized object
// Reject stale deliveries (replay protection).
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected =
"v0," +
crypto.createHmac("sha256", secret).update(`${id}.${ts}.${body}`).digest("base64");
// Any space-separated signature matching = valid (supports secret rotation).
return sigHeader.split(" ").some((s) =>
crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)),
);
}Warning
Retries & reliability
Return a 2xx within a few seconds to acknowledge. Anything else is retried with exponential backoff and jitter over roughly three days (immediately, 5s, 5m, 30m, 2h, 5h, 10h). Delivery is at-least-once, so dedupe on X-Payghaam-Id. Do slow work asynchronously after acknowledging.
