fastify-xstripe
fastify-xstripe
Stripe webhook handling for Fastify v5. Registers a POST webhook route with automatic signature verification, dispatches to 23 built-in subscription/invoice/payment/checkout/charge event handlers (all overridable), and decorates fastify.xStripe with the full Stripe SDK client.
Breaking change (pre-1.0 standardization): the Stripe SDK client decorator was renamed from
fastify.stripetofastify.xStripe. Update everyfastify.stripereference in your application when upgrading. Option-validation error messages were also standardized — see the Error Reference.
Installation
npm install @xenterprises/fastify-xstripe stripe
Quick Start
import Fastify from "fastify";
import xStripe from "@xenterprises/fastify-xstripe";
const fastify = Fastify({ logger: true });
await fastify.register(xStripe, {
apiKey: process.env.STRIPE_API_KEY, // consumer owns env access
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
});
// Full Stripe SDK available anywhere
const customer = await fastify.xStripe.customers.create({ email: "user@example.com" });
await fastify.listen({ port: 3000 });
The plugin never reads process.env itself — your application reads the environment and passes every value in via options.
Options
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
apiKey | string | — | Yes | Stripe secret API key (sk_test_... or sk_live_...). |
webhookSecret | string | — | Yes | Stripe webhook signing secret (whsec_...). |
webhookPath | string | "/stripe/webhook" | No | Path where the webhook POST route is registered. Must start with /. |
handlers | object | {} | No | Custom event handlers keyed by event type. Values must be async functions; they override the default handler for the same event type. |
apiVersion | string | "2024-11-20.acacia" | No | Stripe API version passed to the SDK. |
All options are validated at registration; invalid or missing options throw before the server starts.
Decorated Properties
| Property | Type | Description |
|---|---|---|
fastify.xStripe | Stripe | The initialized Stripe SDK client — use it to call any Stripe API. |
Pages
- Webhook Route — The
POST /stripe/webhookhandler: signature verification, dispatch, custom handler authoring, and testing. - Helpers — Utility functions for formatting amounts, resolving subscription state, extracting invoice data, and more.
Custom Handlers
Override any default handler with your business logic. Each handler receives (event, fastify, stripe):
await fastify.register(xStripe, {
apiKey: process.env.STRIPE_API_KEY,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
handlers: {
"customer.subscription.created": async (event, fastify, stripe) => {
const sub = event.data.object;
await db.users.update({
where: { stripeCustomerId: sub.customer },
data: { subscriptionId: sub.id, status: sub.status },
});
},
"invoice.payment_failed": async (event, fastify, stripe) => {
const invoice = event.data.object;
const customer = await stripe.customers.retrieve(invoice.customer);
await sendEmail(customer.email, "Payment Failed", "Please update your card.");
},
},
});
Default Event Handlers
All 23 built-in handlers log structured data via fastify.log. Override any via the handlers option. Events with no matching handler are acknowledged with processed: false.
Subscription Events
| Event | Logged Fields |
|---|---|
customer.subscription.created | subscriptionId, customerId, status, planId |
customer.subscription.updated | subscriptionId, customerId, status, previous changes |
customer.subscription.deleted | subscriptionId, customerId, canceledAt |
customer.subscription.trial_will_end | subscriptionId, customerId, trialEnd |
customer.subscription.paused | subscriptionId, customerId |
customer.subscription.resumed | subscriptionId, customerId |
Invoice Events
| Event | Logged Fields |
|---|---|
invoice.created | invoiceId, customerId, amount, status |
invoice.finalized | invoiceId, customerId, amount |
invoice.paid | invoiceId, customerId, subscriptionId, amount |
invoice.payment_failed | invoiceId, customerId, amount, attemptCount (warn) |
invoice.upcoming | customerId, subscriptionId, amount, periodEnd |
Payment Events
| Event | Logged Fields |
|---|---|
payment_intent.succeeded | paymentIntentId, customerId, amount, currency |
payment_intent.payment_failed | paymentIntentId, customerId, amount, lastPaymentError (warn) |
Customer Events
| Event | Logged Fields |
|---|---|
customer.created | customerId, email |
customer.updated | customerId, previous changes |
customer.deleted | customerId |
Payment Method Events
| Event | Logged Fields |
|---|---|
payment_method.attached | paymentMethodId, customerId, type |
payment_method.detached | paymentMethodId, type |
Checkout Events
| Event | Logged Fields |
|---|---|
checkout.session.completed | sessionId, customerId, subscriptionId, mode, paymentStatus |
checkout.session.expired | sessionId |
Charge Events
| Event | Logged Fields |
|---|---|
charge.succeeded | chargeId, customerId, amount, currency, paymentMethod |
charge.failed | chargeId, customerId, amount, failureCode, failureMessage (error) |
charge.refunded | chargeId, customerId, amountRefunded, refundCount |
Helper Utilities
Import from @xenterprises/fastify-xstripe/helpers or via named export:
import { helpers } from "@xenterprises/fastify-xstripe";
| Helper | Signature | Description |
|---|---|---|
formatAmount(amount, currency) | (number, string) => string | Format Stripe amount to currency string, e.g. 2000, "USD" → "$20.00". |
getPlanName(subscription) | (sub) => string | Get the plan name from a subscription object. |
isActiveSubscription(subscription) | (sub) => boolean | Check if subscription status is "active" or "trialing". |
isInTrial(subscription) | (sub) => boolean | Check if subscription is in trial period. |
getDaysUntilTrialEnd(subscription) | (sub) => number | Days remaining in trial. |
isRenewal(event) | (event) => boolean | Check if an invoice event is a renewal. |
calculateMRR(subscription) | (sub) => number | Calculate MRR in cents. |
getSubscriptionStatusText(status) | (string) => string | Human-readable status, e.g. "active" → "Active". |
getEventDescription(event) | (event) => string | Human-readable event description. |
getCustomerEmail(event, stripe) | (event, stripe) => Promise<string> | Resolve customer email from event. |
isTestEvent(event) | (event) => boolean | Check if event is from test mode. |
getMetadata(event) | (event) => object | Extract metadata from event. |
getPaymentMethodType(paymentMethod) | (pm) => string | Human-readable payment method type, e.g. "Card". |
getInvoiceLineItems(invoice) | (invoice) => array | Get line items from invoice. |
isSubscriptionInvoice(invoice) | (invoice) => boolean | Check if invoice is subscription-related. |
getNextBillingDate(subscription) | (sub) => Date | Get next billing date as a Date object. |
formatDate(timestamp) | (number) => string | Format Unix timestamp to readable date string. |
Error Reference
All errors are plain Error objects whose messages name the plugin (xstripe), the option, and a usage example.
Startup Errors (thrown at registration)
| Error | Cause |
|---|---|
xstripe: missing required option `apiKey` (string), e.g. `app.register(xStripe, { apiKey: 'sk_test_...' })` | apiKey option not provided. |
xstripe: option `apiKey` must be a non-empty string, e.g. `app.register(xStripe, { apiKey: 'sk_test_...' })` | apiKey is not a string or is empty. |
xstripe: missing required option `webhookSecret` (string), e.g. `app.register(xStripe, { webhookSecret: 'whsec_...' })` | webhookSecret option not provided. |
xstripe: option `webhookSecret` must be a non-empty string, e.g. `app.register(xStripe, { webhookSecret: 'whsec_...' })` | webhookSecret is not a string or is empty. |
xstripe: option `webhookPath` must be a string starting with '/', e.g. `app.register(xStripe, { webhookPath: '/stripe/webhook' })` | webhookPath is not a string starting with /. |
xstripe: option `handlers` must be a plain object mapping event types to functions, e.g. `app.register(xStripe, { handlers: { 'invoice.paid': async (event, fastify, stripe) => {} } })` | handlers is not a plain object, is an array, or contains non-function values. |
xstripe: option `apiVersion` must be a non-empty string, e.g. `app.register(xStripe, { apiVersion: '2024-11-20.acacia' })` | apiVersion is not a string or is empty. |
Webhook Runtime Errors (HTTP 400)
| Error | Cause |
|---|---|
xstripe: missing stripe-signature header | Webhook request received without a stripe-signature header. |
xstripe: webhook signature verification failed: <message> | Invalid or tampered webhook signature. Unverified payloads are never dispatched to handlers. |
Handler exceptions are logged and acknowledged with HTTP 200 ({ received: true, processed: false, error }) so Stripe does not immediately retry.
Environment Variables
The plugin never reads process.env itself. These variables are the consumer-side convention: your application reads them and passes the values into app.register(xStripe, { ... }).
| Variable | Description |
|---|---|
STRIPE_API_KEY | Stripe secret key (sk_test_... or sk_live_...). Pass as the required apiKey option. |
STRIPE_WEBHOOK_SECRET | Webhook signing secret from Stripe Dashboard or CLI (whsec_...). Pass as the required webhookSecret option. |
How It Works
On registration, the plugin validates all options, initializes the Stripe SDK with the provided apiKey and apiVersion, and decorates fastify.xStripe. A POST route is registered at webhookPath inside an encapsulated context with a raw-body content parser (required for signature verification, so no extra Fastify configuration is needed). The route verifies the Stripe signature using stripe.webhooks.constructEvent() before any processing, then dispatches the event to the resolved handler — user handlers are merged over defaults via object spread ({ ...defaultHandlers, ...handlers }). If a handler throws, the error is logged but the route still returns HTTP 200 to prevent Stripe from retrying the event. The fastify.xStripe decorator gives full programmatic access to the Stripe SDK for any API call outside the webhook flow.
Testing Webhooks Locally
# Install Stripe CLI and forward webhooks to your local server
stripe listen --forward-to localhost:3000/stripe/webhook
# Trigger test events
stripe trigger customer.subscription.created
stripe trigger invoice.payment_failed
stripe trigger checkout.session.completed
AI Context
package: "@xenterprises/fastify-xstripe"
type: fastify-plugin
use-when: Stripe webhook handling with signature verification and full Stripe SDK access
decorator: fastify.xStripe (full Stripe SDK client — renamed from fastify.stripe in the pre-1.0 standardization)
webhook-route: POST /stripe/webhook (configurable via webhookPath)
default-handlers: 23 built-in handlers for subscription/invoice/payment/customer/checkout/charge events — all overridable
env: STRIPE_API_KEY, STRIPE_WEBHOOK_SECRET — consumer reads env and passes values via options; the plugin never reads process.env
helper-exports: formatAmount, getPlanName, isActiveSubscription, isInTrial, getDaysUntilTrialEnd, calculateMRR, and more
