Use when a payment integration needs refund, dispute or payment-webhook tests: a refund matrix (full, partial, over-refund) for Stripe, Adyen, PayPal and Braintree, chargebacks with card-network reason codes, and webhook replay through each gateway's simulator.
95%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
Deep reference for payment-flow-test-author SKILL.md Phase 3. The
per-gateway mechanics for driving and replaying real webhook events, and the
signature-verification gauntlet each gateway needs.
Each gateway ships a way to (re)send a real event at the handler:
| Gateway | Replay | Notes |
|---|---|---|
| Stripe | stripe trigger payment_intent.succeeded; stripe events resend evt_test_12345 | CLI (stripe-cli); resend covers a 30-day window (cli/events/resend) |
| Adyen | Customer Area transaction "Resend webhook" | Re-sends with the original signature - useful for idempotency tests |
| PayPal | Dashboard simulator; REST equivalent via the API | simulate-event |
| Braintree | gateway.webhookTesting.sampleNotification(kind, id) | Generates a test signature for any event kind (parse/node) |
The scheme differs per gateway; run the same four-case gauntlet against each:
| Gateway | Signature scheme | Reference |
|---|---|---|
| Stripe | HMAC-SHA256 over the payload; the signature carries a timestamp, so old timestamps reject | webhooks/signatures |
| Adyen | HMAC-SHA256 over the canonical string; validated per-event, not per-request | verify-hmac-signatures |
| PayPal | SHA256-with-RSA; verify via the PayPal verification endpoint or SDK helper | webhooks/rest |
| Braintree | parser validates bt_signature against the merchant's public key | Braintree webhook parser |
Per docs.stripe.com/webhooks/signatures, the gauntlet is four cases - unsigned, wrong-secret, valid, and expired-timestamp:
const payload = JSON.stringify({ type: 'payment_intent.succeeded' });
test('rejects unsigned payload', async () => {
const res = await fetch('/webhooks/stripe', { method: 'POST', body: payload });
expect(res.status).toBe(401); // No Stripe-Signature header
});
test('rejects wrong-secret signature', async () => {
const wrongSig = stripe.webhooks.generateTestHeaderString({ payload, secret: 'wrong-secret' });
const res = await fetch('/webhooks/stripe', {
method: 'POST', body: payload, headers: { 'stripe-signature': wrongSig },
});
expect(res.status).toBe(401);
});
test('accepts valid signature', async () => {
const sig = stripe.webhooks.generateTestHeaderString({
payload, secret: process.env.STRIPE_WEBHOOK_SECRET!,
});
const res = await fetch('/webhooks/stripe', {
method: 'POST', body: payload, headers: { 'stripe-signature': sig },
});
expect(res.status).toBe(200);
});
test('rejects expired timestamp', async () => {
const oldSig = stripe.webhooks.generateTestHeaderString({
payload,
secret: process.env.STRIPE_WEBHOOK_SECRET!,
timestamp: Math.floor(Date.now()/1000) - 3600, // 1 hour ago
});
const res = await fetch('/webhooks/stripe', {
method: 'POST', body: payload, headers: { 'stripe-signature': oldSig },
});
expect(res.status).toBe(401);
});Gotcha: the handler must read the raw request body - a parsed-then-restringified copy fails signature verification on the restringified bytes.