Skip to main content Skip to main content

How to Verify Stripe Webhook Signatures Using Your Stripe Webhook Secret

· 8 min read

Every Stripe webhook event that hits your endpoint could be legitimate, or it could be someone sending fake payment confirmations to your server. Without signature verification, you have no way to tell the difference. That's a problem.

Stripe solves this by cryptographically signing every webhook payload with a secret that only you and Stripe know: the Stripe webhook secret (sometimes called the webhook signing secret). If the signature checks out, you know the event came from Stripe and hasn't been tampered with in transit. If it doesn't, you drop it.

Let's walk through exactly how this works, where to find your secret, and how to implement verification in Python, Node.js, and from scratch.

Why Stripe signs webhooks

Your webhook endpoint is a publicly accessible URL. Anyone who discovers it can POST arbitrary JSON to it. Without signature verification, an attacker could send a fake checkout.session.completed event and trick your app into granting access, fulfilling orders, or crediting accounts.

Stripe's signature scheme gives you two guarantees: the payload was sent by Stripe (authenticity), and it wasn't modified after Stripe sent it (integrity). It also includes a timestamp to protect against replay attacks, where someone captures a valid webhook and sends it again later.

Where to find your webhook signing secret

Each webhook endpoint in Stripe gets its own signing secret. Here's how to find it:

  1. Go to the Stripe Dashboard webhooks page
  2. Click on the endpoint you want to verify
  3. Under "Signing secret," click "Reveal"

The secret looks like whsec_abc123.... That whsec_ prefix tells you it's a webhook signing secret.

Important: if you're using the Stripe CLI to forward webhooks during local development, the CLI gives you a different signing secret when it starts up. Don't mix up your live secret with the CLI secret. They're separate keys.

How the Stripe-Signature header works

Every webhook request from Stripe includes a Stripe-Signature header. It looks something like this:

t=1614556828,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

Two parts, separated by a comma. t is the Unix timestamp of when Stripe sent the event. v1 is the HMAC SHA-256 signature.

To produce the signature, Stripe concatenates the timestamp, a literal period character, and the raw JSON payload body, then computes an HMAC SHA-256 using your webhook secret as the key:

signed_payload = f"{timestamp}.{raw_body}"
expected_sig = hmac_sha256(webhook_secret, signed_payload)

Your server does the same computation and checks whether the result matches the v1 value. If it matches, the webhook is legit.

HMAC Signature Verification Flow A flow diagram showing how Stripe computes an HMAC-SHA256 signature using a shared secret, transmits it via HTTP headers, and how your server independently computes the same hash to verify authenticity. Stripe (Sender) timestamp · raw body Payload to sign HMAC-SHA256( secret , payload) v1=5257a869e7ec… signature HTTP POST Stripe-Signature: t=1614556828, v1=5257a869e7ec… Your Server (Receiver) extract timestamp + body from raw request HMAC-SHA256( secret , payload) compare signatures ★ same shared secret on both sides
Stripe computes the signature using your shared secret. Your server independently computes the same hash. If they match, the request is authentic.

Verifying in Python

The stripe Python library handles all the signature math for you. You just need to pass in the raw request body (not parsed JSON), the signature header, and your webhook secret.

import stripe
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt

WEBHOOK_SECRET = "whsec_..."

@csrf_exempt
def stripe_webhook(request):
    payload = request.body
    sig_header = request.META.get("HTTP_STRIPE_SIGNATURE", "")

    try:
        event = stripe.Webhook.construct_event(
            payload, sig_header, WEBHOOK_SECRET
        )
    except ValueError:
        # Invalid payload
        return HttpResponse(status=400)
    except stripe.error.SignatureVerificationError:
        # Invalid signature
        return HttpResponse(status=400)

    # Handle the event
    if event["type"] == "checkout.session.completed":
        session = event["data"]["object"]
        # Fulfill the order...

    return HttpResponse(status=200)

The critical detail here is request.body. You need the raw bytes, not request.data or json.loads() output. If you parse the JSON first and re-serialize it, the bytes won't match and verification will fail every time.

Verifying in Node.js

Same idea in Express. You need the raw body buffer, not the parsed object.

const stripe = require("stripe")("sk_...");
const express = require("express");

const app = express();
const WEBHOOK_SECRET = "whsec_...";

app.post(
  "/webhook",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const sig = req.headers["stripe-signature"];

    try {
      const event = stripe.webhooks.constructEvent(
        req.body, sig, WEBHOOK_SECRET
      );

      switch (event.type) {
        case "checkout.session.completed":
          const session = event.data.object;
          // Fulfill the order...
          break;
      }

      res.json({ received: true });
    } catch (err) {
      console.log(`Webhook signature failed: ${err.message}`);
      res.status(400).send(`Webhook Error: ${err.message}`);
    }
  }
);

app.listen(4242, () => console.log("Running on 4242"));

Notice express.raw({ type: "application/json" }) on the webhook route. If you're using express.json() globally, it'll parse the body before your handler runs and break signature verification. Apply the raw middleware specifically to your webhook route.

Raw verification without a library

If you're working in a language without an official Stripe library, or you just want to understand what's happening under the hood, here's the raw HMAC logic in Python:

import hmac
import hashlib
import time

def verify_stripe_signature(payload, sig_header, secret, tolerance=300):
    # Parse the header
    pairs = dict(
        pair.split("=", 1)
        for pair in sig_header.split(",")
    )
    timestamp = pairs.get("t")
    signature = pairs.get("v1")

    if not timestamp or not signature:
        raise ValueError("Invalid Stripe-Signature header")

    # Check timestamp tolerance (default 5 minutes)
    if abs(time.time() - int(timestamp)) > tolerance:
        raise ValueError("Timestamp outside tolerance")

    # Compute expected signature
    signed_payload = f"{timestamp}.{payload.decode('utf-8')}"
    expected = hmac.new(
        secret.encode("utf-8"),
        signed_payload.encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()

    # Constant-time comparison
    if not hmac.compare_digest(expected, signature):
        raise ValueError("Signature mismatch")

    return True

A few things to note here. The hmac.compare_digest call is important because it does a constant-time comparison. A regular == check could leak timing information that an attacker might exploit. The tolerance window (300 seconds by default) protects against replay attacks, where someone intercepts a valid webhook and resends it hours later.

Common mistakes

These are the issues that come up most often when developers are integrating Stripe webhooks for the first time:

  • Using the wrong secret. Your live endpoint and your Stripe CLI each have different signing secrets. If you're testing locally with the CLI but using the Dashboard secret, verification will always fail.
  • Parsing the body before verifying. This is the most common one. JSON parsers can reorder keys, change whitespace, or normalize unicode. The signature is computed over the exact bytes Stripe sent. Parse after you verify, not before.
  • Clock drift. If your server's clock is significantly off, the timestamp check will reject legitimate events. The default tolerance is 300 seconds (5 minutes), which is generous, but NTP issues on misconfigured servers can still cause problems.
  • Forgetting to update the secret after rotating. Stripe lets you rotate your webhook signing secret. When you do, both the old and new secrets are active for a brief period, but you still need to update your environment variables.

Testing signatures during development

The Stripe CLI is the standard way to test webhooks locally. Run stripe listen --forward-to localhost:8000/webhook and it'll print a signing secret for you to use. Trigger test events with stripe trigger checkout.session.completed and they'll hit your local server with valid signatures.

For a more visual approach, you can use Payloader's Stripe webhook inspector to capture and examine incoming webhook payloads before writing your handler. It parses Stripe events automatically, shows you the signature header, and breaks down the payload into a human-readable summary. That can save you a lot of back-and-forth when you're debugging why a particular event isn't triggering the behavior you expect.

Once you've confirmed the payload structure and your signature verification works, you can move your handler to production with confidence.

Wrapping up

Signature verification isn't optional. It's the only thing standing between your webhook endpoint and anyone with curl. The good news is it takes about five lines of code if you're using Stripe's library. Find your webhook secret in the Dashboard, pass the raw body to construct_event, and handle the error cases. That's it.

If you're still building out your Stripe integration, check out our guide on testing Stripe webhooks in development for a complete walkthrough of the local development workflow.