Webhooks
Elixpo Pay POSTs signed events to your app's webhook endpoint. Set the URL and choose which events to receive under Entitlement webhook on your product's page; verify each delivery with your per-app signing secret.
Events you can subscribe to
entitlement.updated— a buyer's access was granted, changed, or expired. Required — this is how you fulfill purchases.payment.captured— a payment succeeded. Optional; useful for receipts, analytics, or your own ledger.
Each endpoint only receives the events it's subscribed to. The required event is always on; toggle the optional ones in the dashboard. More event types will appear here over time.
Set it up in the dashboard
Open your product → Entitlement webhook, then:
- Endpoint URL — the route in your app that receives events (e.g.
https://yourapp.com/api/billing/grant). Must behttps. - ☑ entitlement.updated — leave this on. It's how a purchase actually unlocks access (and how downgrades/revocations reach you). Without it, payments succeed but users never get access.
- ☐ payment.captured — check this only if you want a ping on every successful charge (receipts, your own analytics/ledger). Not needed to grant access.
- Signing secret — copy the
whsec_…shown on save and store it asELIXPO_PAY_WEBHOOK_SECRETin your app. You verify every delivery with it. Roll secret rotates it with a grace window (old + new both valid for a bit).
Respond 2xx fast. Failures are recorded and shown as failed deliveries you can inspect.
Request
POST <your endpoint>
Content-Type: application/json
X-Elixpo-Pay-Event: entitlement.updated
X-Elixpo-Pay-Timestamp: 1718500000
X-Elixpo-Pay-Signature: sha256=<hex HMAC of `${timestamp}.${rawBody}`>{
"id": "whd_…",
"type": "entitlement.updated",
"created": 1718500000,
"data": {
"app": "lixblogs",
"uid": "u_123",
"tier": "member",
"status": "active",
"active": true,
"expires_at": "2026-07-16 12:00:00",
"version": 3
}
}payment.captured
Same envelope and signature; the type and data differ. Delivered only if you've enabled it.
{
"id": "whd_…",
"type": "payment.captured",
"created": 1718500000,
"data": {
"app": "lixblogs",
"uid": "u_123",
"transaction_id": "txn_…",
"provider_payment_id": "pay_…",
"provider_order_id": "order_…",
"currency": "INR",
"amount": 19900,
"tier": "member"
}
}Subscription lifecycle (autopay tiers)
For autopay (recurring) prices we still surface everything through entitlement.updated — you do NOT subscribe to separate subscription events. The status flag on the payload tells you what changed:
- First charge / renewal:
{ active: true, status: 'active' }with a newexpires_atpushed forward by one cycle. Treat the same as a one-time purchase — the entitlement is granted. - Buyer cancelled from your site:
{ active: true, status: 'cancelled' }— fires immediately (we call this inline fromPOST /v1/subscriptions/cancel, not at period-end). Buyer keeps access untilexpires_at, then a second event arrives withactive: false. Send the cancellation confirmation email on this first event; flip the tier in your DB on the second. - Mandate ended (UPI revoke from GPay / PhonePe, or repeated card failures):
{ active: true, status: 'halted', failed: true }— Razorpay collapses both UPI mandate revocation AND exhausted-retry card failures into a singlehaltedstate. Treat as a cancellation for UI / tier-state purposes (the sub will not renew), AND surface "update payment to resume" copy in case it was a card problem. Recovery is possible if the buyer re-subscribes.
Three terminal-ish states map to one user-facing state: status: 'cancelled' (cancel API called), status: 'halted' (mandate broken — UPI revoked OR card declined), and the eventual active: false at period_end. All three should result in your app showing the same "subscription ended" UX with the buyer's access valid through expires_at on the first two and stopped on the third.
// example: cancelled-but-still-active envelope
{
"id": "evt_…",
"type": "entitlement.updated",
"created": 1734812345,
"data": {
"app": "blogs",
"uid": "u_123",
"tier": "member",
"active": true,
"status": "cancelled",
"expires_at": "2026-07-22 00:00:00",
"provider_subscription_id": "sub_…"
}
}Verifying
Recompute the HMAC over `${timestamp}.${rawBody}`using your ELIXPO_PAY_WEBHOOK_SECRET (the whsec_… from the dashboard) and compare in constant time. Reject stale timestamps. Branch on type since one endpoint may receive several event types. When you roll the secret with a grace window, the signature header carries several comma-separated values (new + old) — accept if any matches, so you can redeploy without dropping deliveries.
import crypto from "node:crypto";
export async function POST(req) {
const raw = await req.text();
const ts = req.headers.get("x-elixpo-pay-timestamp");
const expected = crypto
.createHmac("sha256", process.env.ELIXPO_PAY_WEBHOOK_SECRET)
.update(ts + "." + raw)
.digest("hex");
// The header may carry several comma-separated signatures during a secret
// rotation grace window — accept if ANY matches.
const sigs = (req.headers.get("x-elixpo-pay-signature") || "")
.split(",")
.map((s) => s.trim().replace("sha256=", ""));
const ok = sigs.some(
(s) => s.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(s))
);
if (!ok) return new Response("bad signature", { status: 401 });
const evt = JSON.parse(raw);
if (evt.type === "entitlement.updated") {
// Upsert users.tier = evt.data.tier with expiry evt.data.expires_at,
// ignoring deliveries with a lower data.version than you've seen.
}
return Response.json({ ok: true });
}Idempotency & ordering
- Each entitlement carries a monotonic
version— ignore anyentitlement.updatedwhose version is ≤ the one you've already applied. - Respond
2xxquickly; non-2xx responses are recorded as failed deliveries for retry/inspection. - The same grant may arrive from both the instant client confirmation and the provider webhook — fulfillment is idempotent on our side.
Revocation & account deletion
Elixpo Pay is wired to Elixpo Accounts (the identity source of truth). When a buyer deletes their account or revokes your app there, Elixpo Pay automatically cancels their subscription (it never renews — billing stops) and revokes the entitlement.
- You receive a final
entitlement.updatedwithstatus: "revoked"andactive: false— handle it like any downgrade and drop the user to your free tier. - This is automatic; you don't call anything. It happens whether or not the buyer still has time left on the period.
- For one-time (P0) plans there's no recurring charge to stop — cancelling the subscription just prevents the next grant. For future recurring plans, the provider mandate is cancelled too.
