← Back to docs

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

  1. Operator panel → Applications → pick one → DeveloperWebhooksAdd endpoint.
  2. Paste your HTTPS URL and subscribe to all events (recommended) or a specific subset.
  3. 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.

EventFires when
user.createdAn end-user account is created (any sign-up path).
user.updatedProfile, email, or metadata changes.
user.deletedAn end-user account is deleted.
user.erasedGDPR erasure — PII/auth hard-deleted, the user can never authenticate again. Propagate the erasure to your own copies. data.user = { id, erasedAt }.
session.revokedA session is revoked (sign-out, admin action, or force-logout).
mfa.enabledThe user turns on TOTP MFA.
mfa.disabledThe user turns off MFA.
password.changedThe user changes their password.
email.verifiedThe user verifies their email address.
subscription.activatedA subscription becomes ACTIVE (new or recovered).
subscription.canceledA subscription is canceled — by you, the end-user, dunning exhaustion, or the provider after failed retries.
subscription.past_dueA renewal payment failed and the subscription is now PAST_DUE.
payment.succeededA payment (initial or renewal) succeeds.
payment.failedA payment attempt fails.
dunning.case_openedFailed-payment recovery starts for a past-due subscription.
dunning.case_recoveredA past-due subscription pays before exhaustion — the case closes recovered.
dunning.case_exhaustedDay-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 occurredAt if you need to reason about sequence.
Need a hand? Talk to the team.