Payment Webhooks
Handle Stripe webhook events for subscription lifecycle, invoice payment, and plan sync.
Endpoint
All Stripe events are delivered to a single endpoint on the API gateway:
POST /api/v1/webhooks/stripeThis endpoint is pre-built in backends/gateway/src/routes/webhooks/stripe.ts. You do not need to write the routing logic — only add handlers for new events if needed.
Signature verification
Always verify the Stripe webhook signature before processing any event. Skipping verification allows anyone to send fake events to your endpoint and manipulate billing state.
Stripe signs every request with the Stripe-Signature header. The handler verifies this against STRIPE_WEBHOOK_SECRET:
import Stripe from "stripe";
import { PLANS } from "@nebutra/billing";
import { db } from "@nebutra/db";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get("stripe-signature");
if (!sig) {
return new Response("Missing stripe-signature header", { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return new Response("Invalid signature", { status: 400 });
}
await handleStripeEvent(event);
return new Response("OK", { status: 200 });
}Handled events
| Stripe event | Handler action |
|---|---|
customer.subscription.updated | Updates tenant plan, status, and currentPeriodEnd in the database |
customer.subscription.deleted | Downgrades tenant to FREE plan |
invoice.payment_succeeded | Logs successful payment; clears any past_due flag |
invoice.payment_failed | Sets tenant status to past_due; triggers payment failure email |
Event handler implementations
async function handleSubscriptionUpdated(
subscription: Stripe.Subscription
) {
const orgId = subscription.metadata.orgId;
const priceId = subscription.items.data[0]?.price.id;
const plan = Object.values(PLANS).find(
(p) => p.stripePriceId === priceId
);
await db.tenant.update({
where: { id: orgId },
data: {
plan: plan?.id ?? "free",
subscriptionStatus: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
}async function handleSubscriptionDeleted(
subscription: Stripe.Subscription
) {
const orgId = subscription.metadata.orgId;
await db.tenant.update({
where: { id: orgId },
data: {
plan: "free",
subscriptionStatus: "canceled",
currentPeriodEnd: null,
},
});
}async function handlePaymentFailed(invoice: Stripe.Invoice) {
const customerId = invoice.customer as string;
const tenant = await db.tenant.findFirst({
where: { stripeCustomerId: customerId },
});
if (!tenant) return;
await db.tenant.update({
where: { id: tenant.id },
data: { subscriptionStatus: "past_due" },
});
// Notify the user via @nebutra/notifications
await notifications.send({
id: crypto.randomUUID(),
type: "billing.payment_failed",
recipientId: tenant.ownerId,
tenantId: tenant.id,
channels: ["email"],
data: { invoiceUrl: invoice.hosted_invoice_url },
});
}Registering events in the Stripe Dashboard
When setting up your production webhook endpoint, enable exactly these events:
customer.subscription.updated
customer.subscription.deleted
invoice.payment_succeeded
invoice.payment_failed
checkout.session.completedGo to Stripe Dashboard → Developers → Webhooks → Add endpoint, enter your API URL, and select the events above.
Local testing with Stripe CLI
# macOS
brew install stripe/stripe-cli/stripe
# npm (cross-platform)
npm install -g stripestripe loginstripe listen --forward-to localhost:3001/api/v1/webhooks/stripeThe CLI prints a local webhook signing secret (whsec_...). Set this as STRIPE_WEBHOOK_SECRET in your .env.local during development.
In a separate terminal, trigger individual events to test your handlers:
# Simulate a successful subscription update
stripe trigger customer.subscription.updated
# Simulate a payment failure
stripe trigger invoice.payment_failedThe Stripe CLI signing secret is different from your production webhook signing secret. Use the CLI-provided secret in .env.local and the Dashboard secret in your production environment.
Idempotency
Stripe may deliver the same event more than once (at-least-once delivery). Make your handlers idempotent — processing the same event twice should produce the same result as processing it once.
The subscription.updated and subscription.deleted handlers above are already idempotent because they use upsert-style database updates.
Related
How is this guide?
Last updated on