Documentation

Outbound webhooks

Receive a signed HTTP POST on your backend for every delivery and engagement event.

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.

info

Note

Looking to call your own API from inside a journey? That's a Connection, not an event webhook. Under Project → Webhooks → Connections you define a reusable target (URL, method, encrypted auth headers, optional signing), then point a journey webhook node at it. The URL, headers, and body support {{ externalId }}, {{ event.x }}, {{ eventName }}, and {{ tag.x }} tokens.

Event types

  • message.sent — accepted by the provider
  • message.delivered — reached the device/inbox
  • message.opened / message.clicked — engagement
  • message.bounced — hard bounce / invalid recipient
  • message.failed — delivery failed
  • message.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.

json
{
  "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:

HeaderMeaning
X-Payghaam-IdEvent id (idempotency key)
X-Payghaam-TimestampUnix seconds of this attempt
X-Payghaam-SignatureSpace-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.

verify.js
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

Warning

Reject requests whose timestamp is outside a tolerance window (±5 min above) to prevent replay. Multiple signatures appear during secret rotation — a match on any one is valid.

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.