Elixpo Pay

Elixpo Docs

Dashboard

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 be https.
  • ☑ 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 as ELIXPO_PAY_WEBHOOK_SECRET in 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

http
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}`>
json
{
  "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.

json
{
  "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 new expires_at pushed 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 from POST /v1/subscriptions/cancel, not at period-end). Buyer keeps access until expires_at, then a second event arrives with active: 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 singlehalted state. 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.

json
// 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.

javascript
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 any entitlement.updated whose version is ≤ the one you've already applied.
  • Respond 2xx quickly; 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.updated with status: "revoked" and active: 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.

Checkout sessionsEntitlements API