stripe-webhook-security — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited stripe-webhook-security (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 webhooks are how a payment provider tells your backend that things happened. If they are wrong — forged, replayed, or processed twice — you ship product without payment, or charge customers twice, or skip a refund. This skill covers the patterns that prevent that.
Applies to Stripe (and largely to other PSPs with similar webhook designs: Paddle, Mollie, Adyen). Examples are Node/Express; the principles port.
A webhook handler must do all three of these. Skipping any one is a bug.
Stripe signs the request with STRIPE_WEBHOOK_SECRET. Verification only works on the exact raw bytes Stripe sent — JSON-parsing first destroys the signature.
// Express — register the raw-body middleware ONLY for the webhook route
import express from 'express';
import Stripe from 'stripe';
const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2024-12-18.acacia' });
// IMPORTANT: do not use express.json() ahead of this route
app.post(
'/webhooks/stripe',
express.raw({ type: 'application/json', limit: '1mb' }),
async (req, res) => {
const sig = req.headers['stripe-signature'] as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body, // Buffer, NOT parsed
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
// Signature invalid — do NOT log the body
return res.status(400).send('invalid signature');
}
await handle(event);
res.json({ received: true });
}
);
// Other routes use JSON parsing normally
app.use(express.json());Common mistakes:
app.use(express.json()) before the webhook route → body is parsed → signature failsStripe will retry webhooks if your endpoint times out or 5xx's. The same event.id may arrive 10 times. Your handler must produce the same effect whether it runs once or ten times.
// Pseudocode: dedup by event.id at the DB layer
async function handle(event: Stripe.Event) {
const inserted = await db.processedEvents.insertIgnoreConflict({
id: event.id,
type: event.type,
received_at: new Date(),
});
if (!inserted) {
// We've already processed this event — return success without re-running side effects
return;
}
await processSideEffects(event);
}In SQL, the dedup table:
CREATE TABLE processed_stripe_events (
id TEXT PRIMARY KEY, -- event.id
type TEXT NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
payload_hash TEXT -- optional: SHA256 of event for forensic comparison
);Insert with ON CONFLICT DO NOTHING; check whether a row was actually inserted to decide whether to run side effects.
Idempotency at the action layer too:
payment_intent.id, not arbitrary order ID. If retried, fulfillment runs once.event.id + recipient so a retry does not double-email.For anything important, do not trust the event body's amount/status/metadata. The event tells you "something happened with pi_xxx"; you then stripe.paymentIntents.retrieve(pi_xxx) and read the authoritative current state.
async function onPaymentIntentSucceeded(event: Stripe.Event) {
const piFromEvent = event.data.object as Stripe.PaymentIntent;
// Re-fetch — authoritative state
const pi = await stripe.paymentIntents.retrieve(piFromEvent.id);
if (pi.status !== 'succeeded') return; // Defensive — state changed since event fired
const amountMinor = pi.amount_received;
const currency = pi.currency;
const orderId = pi.metadata.order_id; // Set during PI creation
await fulfillOrder({ orderId, amountMinor, currency, paymentIntentId: pi.id });
}Why: an event body is fixed at the moment Stripe sent it, but a payment can be partially refunded, disputed, or otherwise mutated by the time your handler runs. For real-money decisions, fetch fresh.
Stripe sends many event types you almost never care about. Listen for exactly the ones you handle, ignore the rest cleanly.
const HANDLED_EVENTS = new Set([
'payment_intent.succeeded',
'payment_intent.payment_failed',
'charge.refunded',
'charge.dispute.created',
'customer.subscription.updated',
'customer.subscription.deleted',
'invoice.payment_failed',
]);
async function handle(event: Stripe.Event) {
if (!HANDLED_EVENTS.has(event.type)) {
// Ignore unknown types — 200 so Stripe doesn't retry
return;
}
switch (event.type) {
case 'payment_intent.succeeded': return onPaymentIntentSucceeded(event);
// ...
}
}In the Stripe Dashboard, also subscribe the endpoint only to the events you handle — reduces noise and reduces what an attacker could craft if the endpoint were somehow misconfigured.
Signature verification with constructEvent includes a timestamp check (default tolerance 5 minutes). An old captured webhook will not replay successfully outside that window.
Belt-and-braces: the dedup table above also catches replays of valid events. Together they cover both attack and accident.
These are the failure modes that show up in real Stripe integrations.
The cents trap. Stripe amounts are in the minor unit (cents, kobo, etc.). 1234 = €12.34. Comparing amount > 100 thinking it's euros is a 100x bug.
The currency trap. A payment_intent.succeeded for an order is only valid if its currency matches what you priced the order in. A €10 order paid in JPY at the wrong rate is fraud-shaped.
if (pi.currency !== order.currency.toLowerCase()) {
await flagForReview(order.id, 'currency mismatch');
return;
}
if (pi.amount_received < order.expectedAmountMinor) {
await flagForReview(order.id, 'underpayment');
return;
}The race trap. Two events for the same order arrive within ms (e.g. payment_intent.succeeded and charge.succeeded). Both try to fulfill. Without idempotency or row-level locks, the order gets fulfilled twice.
The partial-refund trap. charge.refunded does not always mean a full refund. Read charge.amount_refunded and charge.refunded (boolean). Refund-issued ≠ order-fully-refunded.
The dispute trap. charge.dispute.created is the start of a chargeback. The money is gone from your account immediately, but your fulfillment may continue if you don't act on the event. Pause shipping / access; do not auto-refund (you'll double-refund yourself).
If you are running platform / Connect, additional notes:
400 with no detail. Don't echo back what was wrong.stripe listen --forward-to localhost:3000/webhooks/stripe — uses a separate test secret you copy from CLI output.For a webhook handler going to production:
stripe.webhooks.constructEventSTRIPE_WEBHOOK_SECRET matches the endpoint registered in Stripe Dashboardevent.id is in place~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.