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.
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"
}
}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.
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 sig = (req.headers.get("x-elixpo-pay-signature") || "").replace("sha256=", "");
const expected = crypto
.createHmac("sha256", process.env.ELIXPO_PAY_WEBHOOK_SECRET)
.update(ts + "." + raw)
.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
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.