How to Test Stripe Webhooks
Test Stripe webhook handlers with signed checkout, invoice, and payment payloads. Verify Stripe signature verification end-to-end — no Stripe CLI dependency needed.
Updated Mar 2026
Stripe sends webhooks for payments, subscriptions, invoices, and disputes. Every webhook carries a stripe-signature header with an HMAC-SHA256 hash that your handler must verify before processing. If the signature check fails, you reject the request. This means you cannot test your webhook handler with plain HTTP requests -- you need properly signed payloads. webhooks.cc sends Stripe webhooks with valid signatures computed from your webhook secret, captures real Stripe events for replay, and provides templates for common event types.
What you'll build
A test workflow that sends signed Stripe webhooks to your local handler, verifies the response, and runs in your existing test suite. By the end, you will have:
- A Vitest (or any runner) test that sends signed
checkout.session.completedevents to localhost - Custom payload tests for events like
payment_intent.succeededandinvoice.paid - Signature verification on captured real Stripe events
- A pattern you can copy for any Stripe event type
Prerequisites
You need a webhooks.cc API key from your account page and a Stripe test-mode webhook secret.
- webhooks.cc account with an API key (
WHK_API_KEYenv var) - Stripe test-mode webhook secret -- the
whsec_...string from Stripe Dashboard > Developers > Webhooks. This is the endpoint signing secret, not your Stripe API key. - Node.js 18+ and a test runner (Vitest, Jest, or similar)
- A webhook handler running on localhost (e.g.,
http://localhost:3000/api/webhooks/stripe)
Send signed Stripe webhooks to localhost
Use the SDK's sendTo method to send webhooks directly to your local server. The SDK computes the Stripe signature and includes the stripe-signature header. Your handler's existing stripe.webhooks.constructEvent() call works without changes.
Install the SDK
npm install @webhooks-cc/sdkSend a checkout.session.completed event using a template
Templates include realistic payload structure for common Stripe events. The SDK signs the payload with your webhook secret.
import { WebhooksCC } from "@webhooks-cc/sdk";
import { describe, it, expect } from "vitest";
const client = new WebhooksCC({ apiKey: process.env.WHK_API_KEY! });
describe("stripe webhook handler", () => {
it("handles checkout.session.completed", async () => {
const res = await client.sendTo("http://localhost:3000/api/webhooks/stripe", {
provider: "stripe",
template: "checkout.session.completed",
secret: process.env.STRIPE_WEBHOOK_SECRET!,
});
expect(res.status).toBe(200);
});
});Send a custom payload
When you need specific field values -- a particular amount, currency, or customer ID -- pass your own body instead of using a template. The SDK still computes the correct stripe-signature header.
it("handles payment_intent.succeeded with custom amount", async () => {
const res = await client.sendTo("http://localhost:3000/api/webhooks/stripe", {
provider: "stripe",
secret: process.env.STRIPE_WEBHOOK_SECRET!,
body: {
id: "evt_test_payment_success",
type: "payment_intent.succeeded",
data: {
object: {
id: "pi_test_456",
amount: 4999,
currency: "usd",
status: "succeeded",
payment_method: "pm_card_visa",
customer: "cus_test_789",
},
},
created: Math.floor(Date.now() / 1000),
livemode: false,
api_version: "2024-12-18.acacia",
},
});
expect(res.status).toBe(200);
});Test multiple event types
Stripe webhook handlers typically route by event type. Test each branch:
const events = ["checkout.session.completed", "payment_intent.succeeded", "invoice.paid"] as const;
for (const event of events) {
it(`handles ${event}`, async () => {
const res = await client.sendTo("http://localhost:3000/api/webhooks/stripe", {
provider: "stripe",
template: event,
secret: process.env.STRIPE_WEBHOOK_SECRET!,
});
expect(res.status).toBe(200);
});
}Test signature rejection
Verify your handler rejects webhooks signed with the wrong secret:
it("rejects invalid stripe signatures", async () => {
const res = await client.sendTo("http://localhost:3000/api/webhooks/stripe", {
provider: "stripe",
secret: "whsec_wrong_secret_value",
body: {
id: "evt_test_invalid",
type: "checkout.session.completed",
data: { object: {} },
},
});
// Your handler should return 400 when signature verification fails
expect(res.status).toBe(400);
});Capture and verify real Stripe events
When you want to test with payloads from Stripe's actual infrastructure, capture real events through a webhooks.cc endpoint, then verify the signature and inspect the payload.
Create an endpoint and configure it in Stripe
const client = new WebhooksCC({ apiKey: process.env.WHK_API_KEY! });
const endpoint = await client.endpoints.create({ name: "stripe-capture" });
console.log(endpoint.url);
// https://go.webhooks.cc/w/abc123Copy this URL to Stripe Dashboard > Developers > Webhooks > Add endpoint. Select the events you want to capture.
Trigger a Stripe event
In Stripe's test mode, perform the action that fires the webhook. For checkout.session.completed, create a Checkout Session and complete the payment using a test card (4242 4242 4242 4242).
Wait for and verify the captured request
import { WebhooksCC, matchHeader, verifySignature } from "@webhooks-cc/sdk";
const request = await client.requests.waitFor(endpoint.slug, {
timeout: "30s",
match: matchHeader("stripe-signature"),
});
// Verify the signature using your Stripe webhook secret
const result = await verifySignature(request, {
provider: "stripe",
secret: process.env.STRIPE_WEBHOOK_SECRET!,
});
console.log(result.valid); // true
// Inspect the payload
const body = JSON.parse(request.body!);
console.log(body.type); // "checkout.session.completed"
console.log(body.data.object.id); // "cs_test_..."Replay to localhost
Send the captured event to your local handler. The replay preserves the original headers and body, including the Stripe signature.
const res = await client.requests.replay(request.id, "http://localhost:3000/api/webhooks/stripe");
expect(res.status).toBe(200);Replayed Stripe webhooks carry the original timestamp in the signature. If your handler enforces
Stripe's 5-minute tolerance window on the t value, replays of old events will fail signature
verification. For testing, either disable the tolerance check or use sendTo with a fresh
signature instead.
Clean up
await client.endpoints.delete(endpoint.slug);Or use the withEndpoint test utility for automatic cleanup:
import { withEndpoint } from "@webhooks-cc/sdk/testing";
await withEndpoint(client, async (endpoint) => {
// Configure Stripe to send to endpoint.url
// Trigger event, wait for capture, verify
});
// Endpoint is automatically deletedHow Stripe webhook signing works
Stripe signs every webhook with HMAC-SHA256. The stripe-signature header has this format:
t=1700000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Two parts, comma-separated:
| Part | Meaning |
|---|---|
t | Unix timestamp when Stripe generated the signature |
v1 | HMAC-SHA256 hash of {timestamp}.{raw_body} using your webhook secret |
When the SDK sends a Stripe webhook via sendTo, it:
- Takes the JSON body you provide (or the template body)
- Generates a timestamp (or uses your
timestampoverride) - Computes
HMAC-SHA256(secret, "{timestamp}.{body}")using yourwhsec_...secret - Sets the
stripe-signatureheader tot={timestamp},v1={hash}
Your handler calls stripe.webhooks.constructEvent(rawBody, sigHeader, secret), which performs the same computation and compares the hash. If they match, the event is verified.
Available Stripe templates
| Template | Event type | Description |
|---|---|---|
payment_intent.succeeded | payment_intent.succeeded | A payment has been successfully processed |
checkout.session.completed | checkout.session.completed | A Checkout Session has been completed |
invoice.paid | invoice.paid | An invoice payment has succeeded |
Pass any template name to sendTo or sendTemplate:
await client.sendTo(url, {
provider: "stripe",
template: "invoice.paid",
secret: process.env.STRIPE_WEBHOOK_SECRET!,
});For event types without a template, pass a custom body with the type field set to the Stripe event name.
Deterministic signatures for snapshot tests
Pass a fixed timestamp to generate identical signatures across test runs. This is useful for snapshot testing or golden-file comparisons.
const res = await client.sendTo("http://localhost:3000/api/webhooks/stripe", {
provider: "stripe",
secret: "whsec_test_deterministic",
timestamp: 1700000000,
body: {
id: "evt_snapshot_1",
type: "payment_intent.succeeded",
data: { object: { id: "pi_1", amount: 1000, currency: "usd" } },
},
});The same inputs always produce the same stripe-signature header, so your test snapshots stay stable.
Full test file example
A complete Vitest file that tests a Stripe webhook handler:
import { WebhooksCC } from "@webhooks-cc/sdk";
import { describe, it, expect } from "vitest";
const client = new WebhooksCC({ apiKey: process.env.WHK_API_KEY! });
const HANDLER_URL = "http://localhost:3000/api/webhooks/stripe";
const SECRET = process.env.STRIPE_WEBHOOK_SECRET!;
describe("stripe webhook handler", () => {
it("processes checkout.session.completed", async () => {
const res = await client.sendTo(HANDLER_URL, {
provider: "stripe",
template: "checkout.session.completed",
secret: SECRET,
});
expect(res.status).toBe(200);
});
it("processes payment_intent.succeeded", async () => {
const res = await client.sendTo(HANDLER_URL, {
provider: "stripe",
secret: SECRET,
body: {
id: "evt_test_pi",
type: "payment_intent.succeeded",
data: {
object: {
id: "pi_test_001",
amount: 2500,
currency: "usd",
status: "succeeded",
},
},
},
});
expect(res.status).toBe(200);
});
it("processes invoice.paid", async () => {
const res = await client.sendTo(HANDLER_URL, {
provider: "stripe",
template: "invoice.paid",
secret: SECRET,
});
expect(res.status).toBe(200);
});
it("rejects invalid signatures", async () => {
const res = await client.sendTo(HANDLER_URL, {
provider: "stripe",
secret: "whsec_wrong",
body: {
id: "evt_bad_sig",
type: "checkout.session.completed",
data: { object: {} },
},
});
expect(res.status).toBe(400);
});
it("ignores unhandled event types", async () => {
const res = await client.sendTo(HANDLER_URL, {
provider: "stripe",
secret: SECRET,
body: {
id: "evt_unhandled",
type: "charge.dispute.created",
data: {
object: {
id: "dp_test_001",
amount: 1500,
currency: "usd",
},
},
},
});
// Handler should acknowledge receipt even for unhandled types
expect(res.status).toBe(200);
});
});Common issues
"Webhook signature verification failed" -- The webhook secret does not match. Use the whsec_... secret from Stripe Dashboard > Developers > Webhooks > Your endpoint > Signing secret. This is different from your Stripe API key (sk_test_...). If you recently rotated the secret, update your environment variable.
"Handler returns 400" -- Your handler likely fails to read the raw request body. Stripe's constructEvent requires the unmodified body string, not parsed JSON. In Next.js App Router, read await request.text() before parsing. In Express, use express.raw({ type: 'application/json' }) on the webhook route.
"Event type not handled" -- Your handler's switch/if block does not cover the event type you are testing. Check your routing logic. A best practice is to return 200 for all events (even unhandled ones) so Stripe does not retry delivery, and log unhandled types for debugging.
"Tests pass locally but fail in CI" -- Check that WHK_API_KEY and STRIPE_WEBHOOK_SECRET are set as CI secrets. In GitHub Actions:
env:
WHK_API_KEY: ${{ secrets.WHK_API_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}Next steps
Stripe + Vitest Example
End-to-end Stripe assertion pattern with endpoint lifecycle.
Signature Verification
Verify Stripe and 8 other provider signatures with the SDK.
Test Webhooks Locally
Three methods for localhost webhook testing with any provider.
Testing with the SDK
CI/CD integration patterns, waitFor, and test utilities.