stripe-webhook-handling — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited stripe-webhook-handling (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Stripe webhook signature verification requires the exact bytes that Stripe sent. If any middleware or framework feature parses, re-serializes, or transforms the body before verification, the HMAC check fails silently and the webhook is rejected as invalid.
In Fastify, this means the webhook route must capture req.rawBody before Fastify's default JSON content-type parser runs.
Configure Fastify to preserve the raw body on webhook routes. Two approaches:
Option A — addContentTypeParser override (recommended):
async function webhookRoutes(fastify: FastifyInstance) {
// Override content-type parser for this encapsulated context only
fastify.addContentTypeParser(
'application/json',
{ parseAs: 'buffer' },
(req, body, done) => {
// Preserve raw bytes for signature verification
(req as any).rawBody = body;
try {
done(null, JSON.parse(body.toString()));
} catch (err) {
done(err as Error, undefined);
}
}
);
fastify.post('/stripe', stripeWebhookHandler);
}Option B — fastify-raw-body plugin:
import rawBody from 'fastify-raw-body';
fastify.register(rawBody, {
field: 'rawBody',
global: false, // Only on routes that need it
encoding: 'utf8',
runFirst: true,
});import Stripe from 'stripe';
import type { FastifyRequest, FastifyReply } from 'fastify';
const stripe = new Stripe(env.STRIPE_SECRET_KEY);
async function stripeWebhookHandler(
request: FastifyRequest,
reply: FastifyReply
) {
const sig = request.headers['stripe-signature'];
const rawBody = (request as any).rawBody;
if (!sig || !rawBody) {
return reply.status(400).send({ error: 'Missing signature or body' });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
rawBody,
sig,
env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
request.log.warn({ err }, 'Webhook signature verification failed');
return reply.status(400).send({ error: 'Invalid signature' });
}
// Return 200 immediately — process asynchronously
reply.status(200).send({ received: true });
// Dispatch to event handlers (fire-and-forget or queue)
await dispatchEvent(event);
}Route verified events to their handlers using a type-safe dispatch map:
const eventHandlers: Record<string, (event: Stripe.Event) => Promise<void>> = {
'checkout.session.completed': handleCheckoutCompleted,
'customer.subscription.created': handleSubscriptionCreated,
'customer.subscription.updated': handleSubscriptionUpdated,
'customer.subscription.deleted': handleSubscriptionDeleted,
'invoice.paid': handleInvoicePaid,
'invoice.payment_failed': handlePaymentFailed,
};
async function dispatchEvent(event: Stripe.Event): Promise<void> {
const handler = eventHandlers[event.type];
if (!handler) {
// Log unhandled event types but do not fail
return;
}
await handler(event);
}The webhook payload contains the full object at the time the event was created. Risk: the object may have changed between event creation and delivery.
The payload contains only the event type and object id. The handler fetches the latest state from the Stripe API:
async function handleThinEvent(event: StripeThinEvent) {
// Fetch the current state — always up-to-date
const subscription = await stripe.subscriptions.retrieve(event.data.id);
await updateLocalSubscription(subscription);
}During migration, the same logical event may arrive as both a snapshot event and a thin event. Use the snapshot_event correlation field to deduplicate:
// Check if the corresponding snapshot event was already processed
const existing = db.prepare(
'SELECT 1 FROM processed_events WHERE event_id = ? OR snapshot_event_id = ?'
).get(event.id, event.snapshot_event_id);
if (existing) return; // Already handledWebhook routes must skip global JSON body parsing — they need raw bytes. By registering webhook routes in a separate encapsulated Fastify plugin, the custom content-type parser applies only to those routes. The rest of the application continues to use standard JSON parsing.
Never register the raw body parser at the root Fastify instance — it would affect every route in the application.
400. Log a warning (never log the raw body or signature secret).200. Stripe should not retry events the handler does not process.200 anyway if the event was queued. If processing inline, return 500 so Stripe retries (but prefer async processing).~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.