Documentation
Webhooks
ReliPay POSTs a signed JSON event to your backend whenever something happens in an Application — sign-ups, MFA changes, payments, subscription transitions, and dunning. Every delivery carries an HMAC-SHA256 signature so you can prove it came from ReliPay.
Register an endpoint
- Operator panel → Applications → pick one → Developer → Webhooks → Add endpoint.
- Paste your HTTPS URL and subscribe to all events (recommended) or a specific subset.
- Copy the signing secret shown once on creation — store it as an env var. It's the key you verify signatures with, and it's never displayed again (rotate from the endpoint detail page if lost).
The event envelope
Every delivery has the same top-level shape. The per-event payload lives under data. Treat eventId as your idempotency key — retries reuse the same id, so deduping is one cheap upsert.
{
"eventId": "clx9f2a0b0000abcd1234efgh",
"occurredAt": "2026-06-30T09:14:22.084Z",
"type": "subscription.activated",
"applicationId": "cmpsrrrsy0004ml1v174fsf05",
"data": {
"subscription": {
"id": "sub_...",
"status": "ACTIVE",
"endUserId": "usr_...",
"planSlug": "pro-monthly"
}
}
}Delivery headers
The event id and type are mirrored into headers so you can route or dedupe without parsing the body first.
POST /api/relipay/webhook HTTP/1.1 Content-Type: application/json User-Agent: relipay-webhooks/1.0 X-Relipay-Event-Id: clx9f2a0b0000abcd1234efgh X-Relipay-Event-Type: subscription.activated X-Relipay-Signature: t=1751274862,v1=3f8a1c…(64 hex chars)
Verify the signature
The X-Relipay-Signature header is t=<unix-ts>,v1=<hex>. The signed input is `${t}.${rawBody}` — hash it with your endpoint secret and compare in constant time. Verify against the raw request body, before any JSON parsing or re-serialisation. Reject deliveries whose timestamp is more than ~5 minutes old to block replays.
import { createHmac, timingSafeEqual } from "node:crypto";
// secret = the per-endpoint signing secret shown once when you
// created the endpoint (store it as an env var).
export function verifyRelipayWebhook(opts: {
rawBody: string; // the EXACT bytes received — verify before JSON.parse
header: string; // req.headers["x-relipay-signature"]
secret: string;
toleranceSeconds?: number; // default 300 (5 minutes)
}): boolean {
const tolerance = opts.toleranceSeconds ?? 300;
const now = Math.floor(Date.now() / 1000);
// Parse "t=<unix>,v1=<hex>"
const parts = Object.fromEntries(
opts.header.split(",").map((p) => {
const i = p.indexOf("=");
return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
}),
);
const t = Number.parseInt(parts.t ?? "", 10);
const presented = parts.v1 ?? "";
if (!Number.isFinite(t) || !presented) return false;
// Reject replays outside the tolerance window.
if (Math.abs(now - t) > tolerance) return false;
// The signed input is `${t}.${rawBody}` — a different timestamp
// produces a different signature, so a replay can't be re-stamped.
const expected = createHmac("sha256", opts.secret)
.update(`${t}.${opts.rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(presented, "hex");
// Constant-time compare — never use === on the hex strings.
return a.length === b.length && timingSafeEqual(a, b);
}Event catalog
Billing and dunning events fire only when local state actually transitions — a provider replay that changes nothing emits nothing. A provider retry after a 5xx on our side may still re-emit, so always dedupe on eventId.
| Event | Fires when |
|---|---|
user.created | An end-user account is created (any sign-up path). |
user.updated | Profile, email, or metadata changes. |
user.deleted | An end-user account is deleted. |
user.erased | GDPR erasure — PII/auth hard-deleted, the user can never authenticate again. Propagate the erasure to your own copies. data.user = { id, erasedAt }. |
session.revoked | A session is revoked (sign-out, admin action, or force-logout). |
mfa.enabled | The user turns on TOTP MFA. |
mfa.disabled | The user turns off MFA. |
password.changed | The user changes their password. |
email.verified | The user verifies their email address. |
subscription.activated | A subscription becomes ACTIVE (new or recovered). |
subscription.canceled | A subscription is canceled — by you, the end-user, dunning exhaustion, or the provider after failed retries. |
subscription.past_due | A renewal payment failed and the subscription is now PAST_DUE. |
payment.succeeded | A payment (initial or renewal) succeeds. |
payment.failed | A payment attempt fails. |
dunning.case_opened | Failed-payment recovery starts for a past-due subscription. |
dunning.case_recovered | A past-due subscription pays before exhaustion — the case closes recovered. |
dunning.case_exhausted | Day-14 reached without recovery — ReliPay cancels the subscription (provider-side too). |
Retries & idempotency
- Respond 2xx quickly (do slow work async). Any non-2xx or timeout is retried with backoff.
- Retries reuse the same
eventId— dedupe on it so a redelivery is a no-op. - Order is best-effort, not guaranteed. Use
occurredAtif you need to reason about sequence.
