Stripe: webhook signature verification failing [2026 fix]

Webhook signature verification fails when endpoint secret is missing, malformed, or timestamp validation rejects old requests—add correct secret and check system time.

Stripe: Webhook Signature Verification Failing [2am Emergency Fix]

TL;DR

Cause: Your endpoint secret is missing, wrong, or your server's system clock is skewed beyond Stripe's 5-minute tolerance window. Fix: Verify the STRIPE_WEBHOOK_SECRET environment variable matches your Stripe dashboard endpoint, and sync your server time with NTP.

---

Exact Error Messages You're Seeing

``` Error: No signatures found matching the expected signature for payload. ```

``` WebhookSignatureVerificationError: Timestamp outside the tolerance window ```

``` StripeSignatureVerificationError: Unable to extract timestamp and signatures from header ```

``` No API key provided. (If you need help configuring Stripe, visit https://support.stripe.com/contact/email) ```

``` Invalid signature: signature does not match payload ```

---

The Problem: Broken vs. Fixed Code

❌ BROKEN CODE

```javascript // Express.js webhook endpoint const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

app.post('/webhook', express.json(), (req, res) => { // Missing signature verification! const event = req.body; if (event.type === 'charge.succeeded') { console.log('Charge succeeded:', event.data.object); } res.json({received: true}); }); ```

✅ FIXED CODE

```javascript // Express.js webhook endpoint with proper verification const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;

// CRITICAL: Use raw body, not parsed JSON for signature verification app.post( '/webhook', express.raw({type: 'application/json'}), (req, res) => { const sig = req.headers['stripe-signature']; let event;

try { event = stripe.webhooks.constructEvent( req.body, sig, endpointSecret ); } catch (err) { console.error('Webhook signature verification failed:', err.message); return res.status(400).send(Webhook Error: ${err.message}); }

if (event.type === 'charge.succeeded') { console.log('Charge succeeded:', event.data.object); }

res.json({received: true}); } ); ```

Key Differences

| Issue | Broken | Fixed | |-------|--------|-------| | Body parsing | express.json() (parsed) | express.raw() (raw bytes) | | Signature header | Not read | req.headers['stripe-signature'] | | Verification call | Missing | stripe.webhooks.constructEvent() | | Error handling | None | Try-catch with proper HTTP 400 | | Endpoint secret | Not used | Loaded from process.env |

---

Step-by-Step 2am Debugging

1. Verify Environment Variable Exists

```bash

SSH into your production server

echo $STRIPE_WEBHOOK_SECRET

Should output something like: whsec_live_...

If empty, you found the problem

```

2. Check Stripe Dashboard Match

Go to: Stripe Dashboard → Developers → Webhooks → Select your endpoint → Reveal signing secret. Copy the exact value and verify it matches your env var character-for-character.

3. Sync Server Time (Critical!)

Stripe rejects timestamps outside ±5 minutes of current UTC.

```bash

Check current system time

date -u

Sync with NTP (Ubuntu/Debian)

sudo ntpdate -s time.nist.gov

Or use timedatectl (systemd systems)

sudo timedatectl set-ntp true sudo timedatectl status ```

4. Test Webhook Locally

Use Stripe's CLI to send test events:

```bash stripe listen --forward-to localhost:3000/webhook

Copy the signing secret it shows

Update STRIPE_WEBHOOK_SECRET in your .env

In another terminal:

stripe trigger payment_intent.succeeded ```

---

Still Broken? Check These Too

Issue 1: Middleware Order Matters

If you're using body-parser or other middleware, ensure raw Stripe routes load *before* JSON parsing middleware:

```javascript // CORRECT order app.post('/webhook', express.raw({type: 'application/json'}), handler); app.use(express.json()); // After webhook

// WRONG order app.use(express.json()); app.post('/webhook', ...); // Will receive parsed body, signature fails ```

Issue 2: Webhook Secret vs. API Key Confusion

  • STRIPE_SECRET_KEY: For API calls (charges, customers, etc.)
  • STRIPE_WEBHOOK_SECRET: For webhook verification only
  • They are different values. Check you're using the webhook secret in constructEvent().

    Issue 3: Multiple Webhook Endpoints

    If you have multiple endpoints (test + live), verify you're using the correct secret for each environment. Test vs. Live signing secrets are different prefixes (whsec_test_ vs whsec_live_).

    ---

    Related Guides

  • [Stripe API Authentication Issues](/?guide=stripe-auth-errors)
  • [Node.js Environment Variable Loading](/?guide=dotenv-not-loading)
  • ---

    Official Resources

  • [Stripe Webhook Documentation](https://stripe.com/docs/webhooks)
  • [Stripe Signature Verification Guide](https://stripe.com/docs/webhooks/signatures)
  • [Stripe.js constructEvent() Reference](https://stripe.com/docs/api/webhooks/construct_event?lang=node)
  • ---

    Version Note: This guide covers Stripe Node.js SDK v11.0.0+. Earlier versions use stripe.webhooks.constructEventAsync(). Check your package.json version if uncertain.

    Found a different variation? Drop it in the comments.

    🔥 0d
    LIVE
    ↓ PlanetScale rage spiking •⚠ Vercel pricing complaints •↑ Railway gaining fast •↑ Supabase happiness rising •↑ Resend loved by devs •↓ PlanetScale rage spiking •⚠ Vercel pricing complaints •↑ Railway gaining fast •↑ Supabase happiness rising
    DEVELOPER PAIN RADAR // Loading...

    Developers complain.
    Opportunities appear.

    We track what developers are struggling with today — and what opportunities that creates.

    —
    guides today
    —
    avg happiness
    🔥 Pain
    📖 Guides
    🔭 Explore
    👤 Mine
    🔥 Pain Radar — rage scores today
    ↗ share
    💡 Opportunity Feed — pain = market gap
    📈 Tool Momentum
    all scores →
    📖 Latest Guide
    all guides →
    📖 All Guides
    📊 Tool Scores
    + Submit
    📰 Hacker News
    ➕ Submit a Tool
    ← back