Webhooks
Know when a plan changes, a balance runs out or a payment lands, and verify every request.
Add an endpoint under Webhooks in the dashboard and pick the events. Each endpoint has its own whsec_ signing secret.
Events
| Event | When | Typical use |
|---|---|---|
billing.updated | A plan is activated, scheduled, renewed, changed, cancelled or ends. data.plan_changes lists each change with an action (activated, scheduled, updated, expired) and tags say why (renewed, trial_ended, past_due, downgrade_scheduled, quantity_changed) | Sync the customer's plan into your database |
balances.limit_reached | A customer goes from allowed to blocked. limit_type is included, spend_limit, max_purchase or usage_limit | Show an upgrade prompt or email them |
balances.usage_alert_triggered | Usage crosses an alert line | Warn before they run out |
billing.auto_topup_succeeded | An auto top up was charged and granted | Send a receipt |
billing.auto_topup_failed | An auto top up could not run, with a reason | Ask them to update their card |
invoice.created | Revenue built an invoice, including renewals that need a pay link | Email hosted_invoice_url to customers with no saved card |
invoice.paid | An invoice was paid | Send a receipt |
invoice.payment_failed | A charge was declined, or a pay link is still unpaid on a retry day | Ask them to pay or update their card |
customers.created, customers.deleted | A customer record was made or removed | Keep your records in step |
entities.created, entities.deleted | A seat or workspace was added or removed | Seat counts in your app |
balances.usage_alert, the older name, is sent only to endpoints that pick it by name.
Each request is a JSON POST with the headers Revenue-Event, Revenue-Event-Id and Revenue-Signature.
{ "id": "evt_6707...", "type": "balances.limit_reached", "env": "live", "created_at": "2026-10-10T18:00:00.000Z", "data": { "customer_id": "user_123", "feature_id": "videos" } }Verify the signature
Revenue-Signature: t=1760119200,v1=5f2b... where v1 is the hex HMAC SHA256 of the timestamp, a dot and the raw body, keyed with the endpoint secret. Use the raw body, not a parsed and restringified one.
import crypto from "node:crypto";
import express from "express";
const app = express();
app.post("/webhooks/revenue", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("Revenue-Signature") || "";
const parts = Object.fromEntries(header.split(",").map((part) => part.split("=")));
const expected = crypto
.createHmac("sha256", process.env.REVENUE_WEBHOOK_SECRET)
.update(`${parts.t}.${req.body}`)
.digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
const valid = parts.v1 && expected.length === parts.v1.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
if (!fresh || !valid) return res.status(400).end();
const event = JSON.parse(req.body);
handle(event);
res.status(200).end();
});Retries
Answer with any 2xx within ten seconds. Anything else is retried after 1, 5, 30, 120, 360 and 1440 minutes, then marked failed. Every delivery is in the dashboard log and can be replayed by hand. Use Revenue-Event-Id to ignore a delivery you already handled.