puffin-app/docs/stripe-webhooks.md
Matt bc9e2d3782
All checks were successful
Build and Push Docker Images / docker (push) Successful in 1m22s
Implement comprehensive Stripe security fixes and production deployment
CRITICAL SECURITY FIXES:
- Add webhook secret validation to prevent signature bypass
- Implement idempotency protection across all webhook handlers
- Add atomic database updates to prevent race conditions
- Improve CORS security with origin validation and logging
- Remove .env from git tracking to protect secrets

STRIPE INTEGRATION:
- Add support for checkout.session.expired webhook event
- Add Stripe publishable key to environment configuration
- Fix webhook handlers with proper idempotency checks
- Update Order model with atomic updatePaymentAndStatus method
- Add comprehensive logging for webhook processing

DEPLOYMENT ARCHITECTURE:
- Split into two Docker images (frontend-latest, backend-latest)
- Update CI/CD to build separate frontend and backend images
- Configure backend on port 3801 (internal 3001)
- Add production-ready docker-compose.yml
- Remove redundant docker-compose.portainer.yml
- Update nginx configuration for both frontend and backend

DOCUMENTATION:
- Add PRODUCTION-SETUP.md with complete deployment guide
- Add docs/stripe-security-fixes.md with security audit details
- Add docs/stripe-checkout-sessions.md with integration docs
- Add docs/stripe-webhooks.md with webhook configuration
- Update .env.example with all required variables including Stripe publishable key

CONFIGURATION:
- Consolidate to single .env.example template
- Update .gitignore to protect all .env variants
- Add server/Dockerfile for backend container
- Update DEPLOYMENT.md with new architecture

🔒 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 12:18:57 +01:00

144 KiB
Raw Permalink Blame History

Receive Stripe events in your webhook endpoint

Listen for events in your Stripe account on your webhook endpoint so your integration can automatically trigger reactions.

Send events to your AWS account

You can now send events directly to Amazon EventBridge as an event destination.

Create an event destination to receive events at an HTTPS webhook endpoint. After you register a webhook endpoint, Stripe can push real-time event data to your applications webhook endpoint when events happen in your Stripe account. Stripe uses HTTPS to send webhook events to your app as a JSON payload that includes an Event object.

Receiving webhook events helps you respond to asynchronous events, such as when a customers bank confirms a payment, a customer disputes a charge, or a recurring payment succeeds.

Get started

To start receiving webhook events in your app:

  1. Create a webhook endpoint handler to receive event data POST requests.
  2. Test your webhook endpoint handler locally using the Stripe CLI.
  3. Create a new event destination for your webhook endpoint.
  4. Secure your webhook endpoint.

You can register and create one endpoint to handle several different event types at the same time, or set up individual endpoints for specific events.

Unsupported event type behaviors for organization event destinations

Stripe sends most event types asynchronously, but waits for a response for some event types. In these cases, Stripe behaves differently based on whether or not the event destination responds.

If your event destination receives Organization events, those requiring a response have the following limitations:

  • You cant subscribe to issuing_authorization.request for organization destinations. Instead, set up a webhook endpoint in a Stripe account within the organization to subscribe to this event type. Use issuing_authorization.request to authorize purchase requests in real-time.
  • Organization destinations receiving checkout_sessions.completed cant handle redirect behavior when you embed Checkout directly in your website or redirect customers to a Stripe-hosted payment page. To influence Checkout redirect behavior, process this event type with a webhook endpoint configured in a Stripe account within the organization.
  • Organization destinations responding unsuccessfully to an invoice.created event cant influence automatic invoice finalization when using automatic collection. You must process this event type with a webhook endpoint configured in a Stripe account within the organization to trigger automatic invoice finalization.

Create a handler

Use the Stripe API reference to identify the thin event objects or snapshot event objects your webhook handler needs to process.

Set up an HTTP or HTTPS endpoint function that can accept webhook requests with a POST method. If youre still developing your endpoint function on your local machine, it can use HTTP. After its publicly accessible, your webhook endpoint function must use HTTPS.

Set up your endpoint function so that it:

  • Handles POST requests with a JSON payload consisting of an event object.
  • For organization event handlers, it inspects the context value to determine which account in an organization generated the event, then sets the Stripe-Context header corresponding to the context value.
  • Quickly returns a successful status code (2xx) prior to any complex logic that might cause a timeout. For example, you must return a 200 response before updating a customers invoice as paid in your accounting system.

Use our interactive webhook endpoint builder to build a webhook endpoint function in your programming language.

Example endpoint

This code snippet is a webhook function configured to check for received events from a Stripe account, handle the events, and return a 200 responses. Reference the snapshot event handler when you use API v1 resources, and reference the thin event handler when you use API v2 resources.

Snapshot event handler

When you create a snapshot event handler, use the API object definition at the time of the event for your logic by accessing the events data.object fields. You can also retrieve the API resource from the Stripe API to access the latest and up-to-date object definition.

Ruby

require 'json'

# Replace this endpoint secret with your unique endpoint secret key
# If you're testing with the CLI, run 'stripe listen' to find the secret key
# If you defined your endpoint using the API or the Dashboard, check your webhook settings for your endpoint secret: https://dashboard.stripe.com/webhooks
endpoint_secret = 'whsec_...';

# Using Sinatra
post '/webhook' do
  payload = request.body.read
  event = nil

  begin
    event = Stripe::Event.construct_from(
      JSON.parse(payload, symbolize_names: true)
    )
  rescue JSON::ParserError => e
    # Invalid payload
    status 400
    return
  end

  # Check that you have configured webhook signing
  if endpoint_secret
    # Retrieve the event by verifying the signature using the raw body and the endpoint secret
    signature = request.env['HTTP_STRIPE_SIGNATURE'];
    begin
      event = Stripe::Webhook.construct_event(
        payload, signature, endpoint_secret
      )
    rescue Stripe::SignatureVerificationError => e
      puts "⚠️  Webhook signature verification failed. #{e.message}"
      status 400
    end
  end

  # Handle the event
  case event.type
  when 'payment_intent.succeeded'
    payment_intent = event.data.object # contains a Stripe::PaymentIntent
    # Then define and call a method to handle the successful payment intent.
    # handle_payment_intent_succeeded(payment_intent)
  when 'payment_method.attached'
    payment_method = event.data.object # contains a Stripe::PaymentMethod
    # Then define and call a method to handle the successful attachment of a PaymentMethod.
    # handle_payment_method_attached(payment_method)
  # ... handle other event types
  else
    puts "Unhandled event type: #{event.type}"
  end

  status 200
end

Python

import json
from django.http import HttpResponse

# Using Django
# Replace this endpoint secret with your unique endpoint secret key
# If you're testing with the CLI, run 'stripe listen' to find the secret key
# If you defined your endpoint using the API or the Dashboard, check your webhook settings for your endpoint secret: https://dashboard.stripe.com/webhooks
endpoint_secret = 'whsec_...'

@csrf_exempt
def my_webhook_view(request):
  payload = request.body
  event = None

  try:
    event = stripe.Event.construct_from(
      json.loads(payload), stripe.api_key
    )
  except ValueError as e:
    # Invalid payload
    return HttpResponse(status=400)

  if endpoint_secret:
        # Only verify the event if you've defined an endpoint secret
        # Otherwise, use the basic event deserialized with JSON
        sig_header = request.headers.get('stripe-signature')
        try:
            event = stripe.Webhook.construct_event(
                payload, sig_header, endpoint_secret
            )
        except stripe.error.SignatureVerificationError as e:
            print('⚠️  Webhook signature verification failed.' + str(e))
            return jsonify(success=False)

  # Handle the event
  if event.type == 'payment_intent.succeeded':
    payment_intent = event.data.object # contains a stripe.PaymentIntent
    # Then define and call a method to handle the successful payment intent.
    # handle_payment_intent_succeeded(payment_intent)
  elif event.type == 'payment_method.attached':
    payment_method = event.data.object # contains a stripe.PaymentMethod
    # Then define and call a method to handle the successful attachment of a PaymentMethod.
    # handle_payment_method_attached(payment_method)
  # ... handle other event types
  else:
    print('Unhandled event type {}'.format(event.type))

  return HttpResponse(status=200)

PHP


// Replace this endpoint secret with your unique endpoint secret key
// If you're testing with the CLI, run 'stripe listen' to find the secret key
// # If you defined your endpoint using the API or the Dashboard, check your webhook settings for your endpoint secret: https://dashboard.stripe.com/webhooks
$endpoint_secret = 'whsec_...';

$payload = @file_get_contents('php://input');
$event = null;

try {
    $event = \Stripe\Event::constructFrom(
        json_decode($payload, true)
    );
} catch(\UnexpectedValueException $e) {
    // Invalid payload
    http_response_code(400);
    exit();
}

if ($endpoint_secret) {
  // Only verify the event if you've defined an endpoint secret
  // Otherwise, use the basic decoded event
  $sig_header = $_SERVER['HTTP_STRIPE_SIGNATURE'];
  try {
    $event = \Stripe\Webhook::constructEvent(
      $payload, $sig_header, $endpoint_secret
    );
  } catch(\Stripe\Exception\SignatureVerificationException $e) {
    // Invalid signature
    echo '⚠️  Webhook error while validating signature.';
    http_response_code(400);
    exit();
  }
}

// Handle the event
switch ($event->type) {
    case 'payment_intent.succeeded':
        $paymentIntent = $event->data->object; // contains a \Stripe\PaymentIntent
        // Then define and call a method to handle the successful payment intent.
        // handlePaymentIntentSucceeded($paymentIntent);
        break;
    case 'payment_method.attached':
        $paymentMethod = $event->data->object; // contains a \Stripe\PaymentMethod
        // Then define and call a method to handle the successful attachment of a PaymentMethod.
        // handlePaymentMethodAttached($paymentMethod);
        break;
    // ... handle other event types
    default:
        echo 'Received unknown event type ' . $event->type;
}

http_response_code(200);

Java

// Using the Spark framework (http://sparkjava.com)
public Object handle(Request request, Response response) {
 // Replace this endpoint secret with your unique endpoint secret key
 // If you're testing with the CLI, run 'stripe listen' to find the secret key
 // I# If you defined your endpoint using the API or the Dashboard, check your webhook settings for your endpoint secret: https://dashboard.stripe.com/webhooks
  String endpointSecret = "whsec_...";

  String payload = request.body();
  Event event = null;

  try {
    event = ApiResource.GSON.fromJson(payload, Event.class);
  } catch (JsonSyntaxException e) {
    // Invalid payload
    response.status(400);
    return "";
  }
  String sigHeader = request.headers("Stripe-Signature");
  if(endpointSecret != null && sigHeader != null) {
      // Only verify the event if youve defined an endpoint secret
      // Otherwise, use the basic event deserialized with GSON
      try {
          event = Webhook.constructEvent(
              payload, sigHeader, endpointSecret
          );
      } catch (SignatureVerificationException e) {
          // Invalid signature
          System.out.println("⚠️  Webhook error while validating signature.");
          response.status(400);
          return "";
      }
  }
  // Deserialize the nested object inside the event
  EventDataObjectDeserializer dataObjectDeserializer = event.getDataObjectDeserializer();
  StripeObject stripeObject = null;
  if (dataObjectDeserializer.getObject().isPresent()) {
    stripeObject = dataObjectDeserializer.getObject().get();
  } else {
    // Deserialization failed, probably due to an API version mismatch.
    // Refer to the Javadoc documentation on `EventDataObjectDeserializer` for
    // instructions on how to handle this case, or return an error here.
  }

  // Handle the event
  switch (event.getType()) {
    case "payment_intent.succeeded":
      PaymentIntent paymentIntent = (PaymentIntent) stripeObject;
      // Then define and call a method to handle the successful payment intent.
      // handlePaymentIntentSucceeded(paymentIntent);
      break;
    case "payment_method.attached":
      PaymentMethod paymentMethod = (PaymentMethod) stripeObject;
      // Then define and call a method to handle the successful attachment of a PaymentMethod.
      // handlePaymentMethodAttached(paymentMethod);
      break;
    // ... handle other event types
    default:
      System.out.println("Unhandled event type: " + event.getType());
  }

  response.status(200);
  return "";
}

Node.js

const express = require('express');
const app = express();

// Replace this endpoint secret with your unique endpoint secret key
// If you're testing with the CLI, run 'stripe listen' to find the secret key
// If you defined your endpoint using the API or the Dashboard, check your webhook settings for your endpoint secret: https://dashboard.stripe.com/webhooks
const endpointSecret = 'whsec_...';

// The express.raw middleware keeps the request body unparsed;
// this is necessary for the signature verification process
app.post('/webhook', express.raw({type: 'application/json'}), (request, response) => {
  let event;
  if (endpointSecret) {
    // Get the signature sent by Stripe
    const signature = request.headers['stripe-signature'];
    try {
      event = stripe.webhooks.constructEvent(
        request.body,
        signature,
        endpointSecret
      );
    } catch (err) {
      console.log(`⚠️ Webhook signature verification failed.`, err.message);
      return response.sendStatus(400);
    }

  // Handle the event
  switch (event.type) {
    case 'payment_intent.succeeded':
      const paymentIntent = event.data.object;
      // Then define and call a method to handle the successful payment intent.
      // handlePaymentIntentSucceeded(paymentIntent);
      break;
    case 'payment_method.attached':
      const paymentMethod = event.data.object;
      // Then define and call a method to handle the successful attachment of a PaymentMethod.
      // handlePaymentMethodAttached(paymentMethod);
      break;
    // ... handle other event types
    default:
      console.log(`Unhandled event type ${event.type}`);
  }

  // Return a response to acknowledge receipt of the event
  response.json({received: true});
});

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

Go

http.HandleFunc("/webhook", func(w http.ResponseWriter, req *http.Request) {
    const MaxBodyBytes = int64(65536)
    req.Body = http.MaxBytesReader(w, req.Body, MaxBodyBytes)
    payload, err := ioutil.ReadAll(req.Body)
    if err != nil {
        fmt.Fprintf(os.Stderr, "Error reading request body: %v\n", err)
        w.WriteHeader(http.StatusServiceUnavailable)
        return
    }

    event := stripe.Event{}

    if err := json.Unmarshal(payload, &event); err != nil {
        fmt.Fprintf(os.Stderr, "Failed to parse webhook body json: %v\n", err.Error())
        w.WriteHeader(http.StatusBadRequest)
        return
    }

    // Unmarshal the event data into an appropriate struct depending on its Type
    switch event.Type {
    case "payment_intent.succeeded":
        var paymentIntent stripe.PaymentIntent
        err := json.Unmarshal(event.Data.Raw, &paymentIntent)
        if err != nil {
            fmt.Fprintf(os.Stderr, "Error parsing webhook JSON: %v\n", err)
            w.WriteHeader(http.StatusBadRequest)
            return
        }
        // Then define and call a func to handle the successful payment intent.
        // handlePaymentIntentSucceeded(paymentIntent)
    case "payment_method.attached":
        var paymentMethod stripe.PaymentMethod
        err := json.Unmarshal(event.Data.Raw, &paymentMethod)
        if err != nil {
            fmt.Fprintf(os.Stderr, "Error parsing webhook JSON: %v\n", err)
            w.WriteHeader(http.StatusBadRequest)
            return
        }
        // Then define and call a func to handle the successful attachment of a PaymentMethod.
        // handlePaymentMethodAttached(paymentMethod)
    // ... handle other event types
    default:
        fmt.Fprintf(os.Stderr, "Unhandled event type: %s\n", event.Type)
    }

    w.WriteHeader(http.StatusOK)
})

.NET

using System;
using System.IO;
using Microsoft.AspNetCore.Mvc;
using Stripe;

namespace workspace.Controllers
{
    [Route("api/[controller]")]
    public class StripeWebHook : Controller
    {
        [HttpPost]
        public async Task<IActionResult> Index()
        {
            var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();
            const string endpointSecret = "whsec_...";
            try
            {
                var stripeEvent = EventUtility.ParseEvent(json);
                var signatureHeader = Request.Headers["Stripe-Signature"];

                stripeEvent = EventUtility.ConstructEvent(json,signatureHeader, endpointSecret);

                // Handle the event
                // If on SDK version < 46, use class Events instead of EventTypes
                if (stripeEvent.Type == EventTypes.PaymentIntentSucceeded)
                {
                    var paymentIntent = stripeEvent.Data.Object as PaymentIntent;
                    // Then define and call a method to handle the successful payment intent.
                    // handlePaymentIntentSucceeded(paymentIntent);
                }
                else if (stripeEvent.Type == EventTypes.PaymentMethodAttached)
                {
                    var paymentMethod = stripeEvent.Data.Object as PaymentMethod;
                    // Then define and call a method to handle the successful attachment of a PaymentMethod.
                    // handlePaymentMethodAttached(paymentMethod);
                }
                // ... handle other event types
                else
                {
                    // Unexpected event type
                    Console.WriteLine("Unhandled event type: {0}", stripeEvent.Type);
                }
                return Ok();
            }
            catch (StripeException e)
            {
                return BadRequest();
            }
        }
    }
}

Thin event handler (Clover+)

When you create a thin event handler, use the fetchRelatedObject() method to retrieve the latest version of the object associated with the event. Events might contain additional data that you can only retrieve through the .fetchEvent() instance method on EventNotification. The exact shape of that data depends on the type of the Event.

Event types must be available at the time of release to generate classes in that SDK version. To handle Events the SDK doesnt have classes for, use the UnknownEventNotification class.

Python

import os
from stripe import StripeClient
from stripe.events import UnknownEventNotification

from flask import Flask, request, jsonify

app = Flask(__name__)
api_key = os.environ.get("STRIPE_API_KEY", "")
webhook_secret = os.environ.get("WEBHOOK_SECRET", "")

client = StripeClient(api_key)

@app.route("/webhook", methods=["POST"])
def webhook():
    webhook_body = request.data
    sig_header = request.headers.get("Stripe-Signature")

    try:
        event_notif = client.parse_event_notification(
            webhook_body, sig_header, webhook_secret
        )

        # type checkers will narrow the type based on the `type` property
        if event_notif.type == "v1.billing.meter.error_report_triggered":
            # in this block, event_notification is typed as
            # a V1BillingMeterErrorReportTriggeredEventNotification

            # there's basic info about the related object in the notification
            print(f"Meter w/ id {event_notif.related_object.id} had a problem")

            # or you can fetch the full object form the API for more details
            meter = event_notif.fetch_related_object()
            print(
                f"Meter {meter.display_name} ({meter.id}) had a problem"
            )

            # And you can always fetch the full event:
            event = event_notif.fetch_event()
            print(f"More info: {event.data.developer_message_summary}")

        elif event_notif.type == "v1.billing.meter.no_meter_found":
            # in this block, event_notification is typed as
            # a V1BillingMeterNoMeterFoundEventNotification

            # that class doesn't define `fetch_related_object` because the event
            # has no related object.
            # so this line would correctly give a type error:
            # meter = event_notif.fetch_related_object()

            # but fetching the event always works:
            event = event_notif.fetch_event()
            print(
                f"Err! No meter found: {event.data.developer_message_summary}"
            )

        # Events that were introduced after this SDK version release are
        # represented as `UnknownEventNotification`s.
        # They're valid, the SDK just doesn't have corresponding classes for them.
        # You must match on the "type" property instead.
        elif isinstance(event_notif, UnknownEventNotification):
            # these lines are optional, but will give you more accurate typing in this block
            from typing import cast

            event_notif = cast(UnknownEventNotification, event_notif)

            # continue matching on the type property
            # from this point on, the `related_object` property _may_ be None
            # (depending on the event type)
            if event_notif.type == "some.new.event":
                # if this event type has a related object, you can fetch it
                obj = event_notif.fetch_related_object()
                # otherwise, `obj` will just be `None`
                print(f"Related object: {obj}")

                # you can still fetch the full event, but it will be untyped
                event = event_notif.fetch_event()
                print(f"New event: {event.data}")  # type: ignore

        return jsonify(success=True), 200
    except Exception as e:
        return jsonify(error=str(e)), 400

Ruby

require "stripe"
require "sinatra"

api_key = ENV.fetch("STRIPE_API_KEY", nil)
# Retrieve the webhook secret from the environment variable
webhook_secret = ENV.fetch("WEBHOOK_SECRET", nil)

client = Stripe::StripeClient.new(api_key)

post "/webhook" do
  webhook_body = request.body.read
  sig_header = request.env["HTTP_STRIPE_SIGNATURE"]
  event_notification = client.parse_event_notification(webhook_body, sig_header, webhook_secret)

  if event_notification.instance_of?(Stripe::Events::V1BillingMeterErrorReportTriggeredEventNotification)
    # there's basic info about the related object in the notification
    puts "Received event for meter id:", event_notification.related_object.id

    # or you can fetch the full object form the API for more details
    meter = event_notification.fetch_related_object
    puts "Meter #{meter.display_name} (#{meter.id}) had a problem"

    # And you can always fetch the full event:
    event = event_notification.fetch_event
    puts "More info:", event.data.developer_message_summary
  elsif event_notification.instance_of?(Stripe::Events::UnknownEventNotification)
    # Events that were introduced after this SDK version release are
    # represented as `UnknownEventNotification`s.
    # They're valid, the SDK just doesn't have corresponding classes for them.
    # You must match on the "type" property instead.
    if event_notification.type == "some.new.event"
      # your logic goes here
    end
  end

  # Record the failures and alert your team
  status 200
end

PHP

<?php

require 'vendor/autoload.php';

$api_key = getenv('STRIPE_API_KEY');
$webhook_secret = getenv('WEBHOOK_SECRET');

$app = new \Slim\App();
$client = new \Stripe\StripeClient($api_key);

$app->post('/webhook', static function ($request, $response) use ($client, $webhook_secret) {
    $webhook_body = $request->getBody()->getContents();
    $sig_header = $request->getHeaderLine('Stripe-Signature');

    try {
        $event_notification = $client->parseEventNotification($webhook_body, $sig_header, $webhook_secret);

        // check what type of event notification we have
        if ($event_notification instanceof Stripe\Events\V1BillingMeterErrorReportTriggeredEventNotification) {
            // there's basic info about the related object in the notification
            echo "Meter with id {$event_notification->related_object->id} reported an error\n";

            // or you can fetch the full object form the API for more details
            $meter = $event_notification->fetchRelatedObject();
            echo "Meter {$meter->display_name} ({$meter->id}) had a problem\n";

            # And you can always fetch the full event:
            $event = $event_notification->fetchEvent();
            echo "More info: {$event->data->developer_message_summary}\n";
        } else if ($event_notification instanceof Stripe\Events\UnknownEventNotification) {
            // Events that were introduced after this SDK version release are
            // represented as `UnknownEventNotification`s.
            // They're valid, the SDK just doesn't have corresponding classes for them.
            // You must match on the "type" property instead.
            if ($event_notification->type === 'some.new.event') {
                // handle it the same way as above
            }
        }

        return $response->withStatus(200);
    } catch (Exception $e) {
        return $response->withStatus(400)->withJson(['error' => $e->getMessage()]);
    }
});

$app->run();

Java

import com.stripe.StripeClient;
import com.stripe.events.UnknownEventNotification;
import com.stripe.events.V1BillingMeterErrorReportTriggeredEvent;
import com.stripe.events.V1BillingMeterErrorReportTriggeredEventNotification;
import com.stripe.exception.StripeException;
import com.stripe.model.billing.Meter;
import com.stripe.model.v2.core.EventNotification;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;

public class EventNotificationWebhookHandler {
  private static final String API_KEY = System.getenv("STRIPE_API_KEY");
  private static final String WEBHOOK_SECRET = System.getenv("WEBHOOK_SECRET");

  private static final StripeClient client = new StripeClient(API_KEY);

  public static void main(String[] args) throws IOException {

    HttpServer server = HttpServer.create(new InetSocketAddress(4242), 0);
    server.createContext("/webhook", new WebhookHandler());
    server.setExecutor(null);
    server.start();
  }

  static class WebhookHandler implements HttpHandler {
    @Override
    public void handle(HttpExchange exchange) throws IOException {
      if ("POST".equals(exchange.getRequestMethod())) {
        InputStream requestBody = exchange.getRequestBody();
        String webhookBody = new String(requestBody.readAllBytes(), StandardCharsets.UTF_8);
        String sigHeader = exchange.getRequestHeaders().getFirst("Stripe-Signature");

        try {
          EventNotification notif =
              client.parseEventNotification(webhookBody, sigHeader, WEBHOOK_SECRET);

          if (notif instanceof V1BillingMeterErrorReportTriggeredEventNotification) {
            V1BillingMeterErrorReportTriggeredEventNotification eventNotification =
                (V1BillingMeterErrorReportTriggeredEventNotification) notif;

            // there's basic info about the related object in the notification
            System.out.println(
                "Meter w/ id " + eventNotification.getRelatedObject().getId() + " had a problem");

            // or you can fetch the full object form the API for more details
            Meter meter = eventNotification.fetchRelatedObject();
            StringBuilder sb = new StringBuilder();
            sb.append("Meter ")
                .append(meter.getDisplayName())
                .append(" (")
                .append(meter.getId())
                .append(") had a problem");
            System.out.println(sb.toString());

            // And you can always fetch the full event:
            V1BillingMeterErrorReportTriggeredEvent event = eventNotification.fetchEvent();
            System.out.println("More info: " + event.getData().getDeveloperMessageSummary());
          } else if (notif instanceof UnknownEventNotification) {
            // Events that were introduced after this SDK version release are
            // represented as `UnknownEventNotification`s.
            // They're valid, the SDK just doesn't have corresponding classes for them.
            // You must match on the "type" property instead.
            UnknownEventNotification unknownEvent = (UnknownEventNotification) notif;
            if (unknownEvent.getType().equals("some.new.event")) {
              // you can still `.fetchEvent()` and `.fetchRelatedObject()`, but the latter may
              // return `null` if that event type doesn't have a related object.
            }
          }

          exchange.sendResponseHeaders(200, -1);
        } catch (StripeException e) {
          exchange.sendResponseHeaders(400, -1);
        }
      } else {
        exchange.sendResponseHeaders(405, -1);
      }
      exchange.close();
    }
  }
}

Typescript

import {Stripe} from 'stripe';
import express from 'express';

const app = express();

const apiKey = process.env.STRIPE_API_KEY ?? '';
const webhookSecret = process.env.WEBHOOK_SECRET ?? '';

const client = new Stripe(apiKey);

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

    try {
      const eventNotification = client.parseEventNotification(
        req.body,
        sig,
        webhookSecret
      );

      // TS will narrow event based on the `type` property
      if (eventNotification.type == 'v1.billing.meter.error_report_triggered') {
        // this this block, eventNotification is correctly
        // a Stripe.Events.V1BillingMeterErrorReportTriggeredEventNotification

        // there's basic info about the related object in the notification
        console.log(
          `Meter w/ id ${eventNotification.related_object.id} had a problem`
        );

        // or you can fetch the full object from the API for more details
        const meter = await eventNotification.fetchRelatedObject();
        console.log(`Meter ${meter.display_name} (${meter.id}) had a problem`);

        // And you can always fetch the full event:
        const event = await eventNotification.fetchEvent();
        console.log(`More info: ${event.data.developer_message_summary}`);
      } else if (eventNotification.type === 'v1.billing.meter.no_meter_found') {
        // in this block, eventNotification is correctly
        // a Stripe.Events.V1BillingMeterNoMeterFoundEventNotification

        // that interface doesn't define `fetchRelatedObject()` because the event
        // has no related object. so this line would correctly give a type error:
        // eventNotification.fetchRelatedObject();

        // but fetching the event always works:
        const event = await eventNotification.fetchEvent();
        console.log(
          `Err: No meter found: ${event.data.developer_message_summary}`
        );
      // Events that were introduced after this SDK version release are
      // represented as `UnknownEventNotification`s.
      // They're valid, the SDK just doesn't have corresponding classes for them.
      // In that case, you ignore the type mismatch and cast to UnknownEventNotification
      // @ts-expect-error
      } else if (eventNotification.type === 'some.new.event') {
        const unknownEvent = eventNotification as Stripe.Events.UnknownEventNotification;

        // you can still fetch the related object, if one exists
        // but its type is `unknown`
        const obj = await unknownEvent.fetchRelatedObject();

        // and you can still fetch the event:
        const event = await unknownEvent.fetchEvent();
        // @ts-expect-error
        console.log(`Got new event: ${event.data}`);
      }

      res.sendStatus(200);
    } catch (err) {
      console.log(`Webhook Error: ${(err as any).stack}`);
      res.status(400).send(`Webhook Error: ${(err as any).message}`);
    }
  }
);

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

Go

package main

import (
  "context"
  "io"
  "log/slog"
  "net/http"
  "os"

  "github.com/stripe/stripe-go/v83"
)

func main() {
	http.HandleFunc("/webhook", func(w http.ResponseWriter, req *http.Request) {
		const MaxBodyBytes = int64(65536)
		req.Body = http.MaxBytesReader(w, req.Body, MaxBodyBytes)
		payload, err := io.ReadAll(req.Body)
		if err != nil {
			fmt.Fprintf(os.Stderr, "Error reading request body: %v\n", err)
			w.WriteHeader(http.StatusInternalServerError)
			return
		}

		eventNotification, err := client.ParseEventNotification(payload, req.Header.Get("Stripe-Signature"), webhookSecret)
		if err != nil {
			fmt.Fprintf(os.Stderr, "Error reading request body: %v\n", err)
			w.WriteHeader(http.StatusInternalServerError)
			return
		}

		// Unmarshal the event data into an appropriate struct depending on its Type
		switch evt := eventNotification.(type) {
		case *stripe.V1BillingMeterErrorReportTriggeredEventNotification:
			// there's basic info about the related object in the notification
			fmt.Printf("Meter w/ id %s had a problem\n", evt.RelatedObject.ID)

			// or you can fetch the full object form the API for more details
			meter, err := evt.FetchRelatedObject(context.TODO())
			if err != nil {
				fmt.Fprintf(os.Stderr, "Error fetching related object: %v\n", err)
				w.WriteHeader(http.StatusInternalServerError)
				return
			}
			sb := fmt.Sprintf("Meter %s (%s) had a problem", meter.DisplayName, meter.ID)
			fmt.Println(sb)

			// And you can always fetch the full event:
			event, err := evt.FetchEvent(context.TODO())
			if err != nil {
				fmt.Fprintf(os.Stderr, "Error fetching event: %v\n", err)
				w.WriteHeader(http.StatusInternalServerError)
				return
			}
			fmt.Printf("More info: %s\n", event.Data.DeveloperMessageSummary)
		case *stripe.UnknownEventNotification:
			// Events that were introduced after this SDK version release are
      // represented as `UnknownEventNotification`s.
      // They're valid, the SDK just doesn't have corresponding classes for them.
      // You must match on the "type" property instead.
			switch evt.Type {
			case "some.new.event":
				// you can still `.FetchEvent()` and `.FetchRelatedObject()`, but the latter may
				// return `nil` if that event type doesn't have a related object.
				return
			}

		default:
			fmt.Fprintf(os.Stderr, "Purposefully skipping the handling of event w/ type: %s\n", evt.GetEventNotification().Type)
		}

		w.WriteHeader(http.StatusOK)
	})

	err := http.ListenAndServe(":4242", nil)
	if err != nil {
		fmt.Println(err)
		os.Exit(1)
	}
}

.NET

using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Stripe;
using Stripe.Events;
[Route("api/[controller]")]
[ApiController]
public class EventNotificationWebhookHandler : ControllerBase
{
    private readonly StripeClient client;
    private readonly string webhookSecret;

    public EventNotificationWebhookHandler()
    {
        var apiKey = Environment.GetEnvironmentVariable("STRIPE_API_KEY");
        client = new StripeClient(apiKey);

        webhookSecret = Environment.GetEnvironmentVariable("WEBHOOK_SECRET") ?? string.Empty;
    }

    [HttpPost]
    public async Task<IActionResult> Index()
    {
        var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();
        try
        {
            var eventNotification = client.ParseEventNotification(json, Request.Headers["Stripe-Signature"], webhookSecret);

            // match on the type of the class to determine what event you have
            if (eventNotification is V1BillingMeterErrorReportTriggeredEventNotification notif)
            {
                // there's basic info about the related object in the notification
                Console.WriteLine(
                    $"Meter w/ id {notif.RelatedObject.Id} had a problem");

                // or you can fetch the full object form the API for more details
                var meter = await notif.FetchRelatedObjectAsync();
                Console.WriteLine($"Meter {meter.DisplayName} ({meter.Id}) had a problem");

                // And you can always fetch the full event:
                var evt = await notif.FetchEventAsync();
                Console.WriteLine($"More info: {evt.Data.DeveloperMessageSummary}");
            }
            else if (eventNotification is UnknownEventNotification unknownEvt)
            {
                // Events that were introduced after this SDK version release are
                // represented as `UnknownEventNotification`s.
                // They're valid, the SDK just doesn't have corresponding classes for them.
                // You must match on the "type" property instead.
                if (unknownEvt.Type == "some.other.event")
                {
                    // you can still `.fetchEvent()` and `.fetchRelatedObject()`, but the latter may
                    // return `null` if that event type doesn't have a related object.
                }
            }

            return Ok();
        }
        catch (StripeException e)
        {
            return BadRequest(e.Message);
        }
    }
}

Thin event handler (Acacia or Basil)

When you create a thin event handler, use the fetchRelatedObject() method to retrieve the latest version of the object associated with the event. Thin events might contain additional contextual data that you can only retrieve with the API. Use the retrieve() call with the thin event ID to access these additional payload fields.

Python

import os
from stripe import StripeClient
from stripe.events import V1BillingMeterErrorReportTriggeredEvent

from flask import Flask, request, jsonify

app = Flask(__name__)
api_key = os.environ.get('STRIPE_API_KEY')
webhook_secret = os.environ.get('WEBHOOK_SECRET')

client = StripeClient(api_key)

@app.route('/webhook', methods=['POST'])
def webhook():
    webhook_body = request.data
    sig_header = request.headers.get('Stripe-Signature')

try:
    thin_event = client.parse_thin_event(webhook_body, sig_header, webhook_secret)

    # Fetch the event data to understand the failure
    event = client.v2.core.events.retrieve(thin_event.id)
    if isinstance(event, V1BillingMeterErrorReportTriggeredEvent):
        meter = event.fetch_related_object()
        meter_id = meter.id

        # Record the failures and alert your team
        # Add your logic here

    return jsonify(success=True), 200
except Exception as e:
    return jsonify(error=str(e)), 400

if __name__ == '__main__':
    app.run(port=4242)

Ruby

require "stripe"
require "sinatra"

api_key = ENV.fetch("STRIPE_API_KEY", nil)
# Retrieve the webhook secret from the environment variable
webhook_secret = ENV.fetch("WEBHOOK_SECRET", nil)

client = Stripe::StripeClient.new(api_key)

post "/webhook" do
  webhook_body = request.body.read
  sig_header = request.env["HTTP_STRIPE_SIGNATURE"]
  thin_event = client.parse_thin_event(webhook_body, sig_header, webhook_secret)

  # Fetch the event data to understand the failure
  event = client.v2.core.events.retrieve(thin_event.id)
  if event.instance_of? Stripe::V1BillingMeterErrorReportTriggeredEvent
    meter = event.fetch_related_object
    meter_id = meter.id
  end

  # Record the failures and alert your team
  # Add your logic here
  status 200
end

PHP

<?php

require 'vendor/autoload.php';

$api_key = getenv('STRIPE_API_KEY');
$webhook_secret = getenv('WEBHOOK_SECRET');

$app = new \Slim\App();
$client = new \Stripe\StripeClient($api_key);

$app->post('/webhook', function ($request, $response) use ($client, $webhook_secret) {
    $webhook_body = $request->getBody()->getContents();
    $sig_header = $request->getHeaderLine('Stripe-Signature');

    try {
        $thin_event = $client->parseThinEvent($webhook_body, $sig_header, $webhook_secret);

        // Fetch the event data to understand the failure
        $event = $client->v2->core->events->retrieve($thin_event->id);
        if ($event instanceof \Stripe\Events\V1BillingMeterErrorReportTriggeredEvent) {
            $meter = $event->fetchRelatedObject();
            $meter_id = $meter->id;

            // Record the failures and alert your team
            // Add your logic here
        }
        return $response->withStatus(200);
    } catch (\Exception $e) {
        return $response->withStatus(400)->withJson(['error' => $e->getMessage()]);
    }
});

$app->run();

Java

import com.stripe.StripeClient;
import com.stripe.events.V1BillingMeterErrorReportTriggeredEvent;
import com.stripe.exception.StripeException;
import com.stripe.model.ThinEvent;
import com.stripe.model.billing.Meter;
import com.stripe.model.v2.Event;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;

public class StripeWebhookHandler {
  private static final String API_KEY = System.getenv("STRIPE_API_KEY");
  private static final String WEBHOOK_SECRET = System.getenv("WEBHOOK_SECRET");

  private static final StripeClient client = new StripeClient(API_KEY);

  public static void main(String[] args) throws IOException {

    HttpServer server = HttpServer.create(new InetSocketAddress(4242), 0);
    server.createContext("/webhook", new WebhookHandler());
    server.setExecutor(null);
    server.start();
  }

  static class WebhookHandler implements HttpHandler {
    @Override
    public void handle(HttpExchange exchange) throws IOException {
      if ("POST".equals(exchange.getRequestMethod())) {
        InputStream requestBody = exchange.getRequestBody();
        String webhookBody = new String(requestBody.readAllBytes(), StandardCharsets.UTF_8);
        String sigHeader = exchange.getRequestHeaders().getFirst("Stripe-Signature");

        try {
          ThinEvent thinEvent = client.parseThinEvent(webhookBody, sigHeader, WEBHOOK_SECRET);

          // Fetch the event data to understand the failure
          Event baseEvent = client.v2().core().events().retrieve(thinEvent.getId());
          if (baseEvent instanceof V1BillingMeterErrorReportTriggeredEvent) {
            V1BillingMeterErrorReportTriggeredEvent event =
                (V1BillingMeterErrorReportTriggeredEvent) baseEvent;
            Meter meter = event.fetchRelatedObject();

            String meterId = meter.getId();

            // Record the failures and alert your team
            // Add your logic here
          }

          exchange.sendResponseHeaders(200, -1);
        } catch (StripeException e) {
          exchange.sendResponseHeaders(400, -1);
        }
      } else {
        exchange.sendResponseHeaders(405, -1);
      }
      exchange.close();
    }
  }
}

Node.js

const express = require('express');
const {Stripe} = require('stripe');

const app = express();

const apiKey = process.env.STRIPE_API_KEY;
const webhookSecret = process.env.WEBHOOK_SECRET;

const client = new Stripe(apiKey);

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

    try {
      const thinEvent = client.parseThinEvent(req.body, sig, webhookSecret);

      // Fetch the event data to understand the failure
      const event = await client.v2.core.events.retrieve(thinEvent.id);
      if (event.type == 'v1.billing.meter.error_report_triggered') {
        const meter = await event.fetchRelatedObject();
        const meterId = meter.id;
        // Record the failures and alert your team
        // Add your logic here
      }
      res.sendStatus(200);
    } catch (err) {
      console.log(`Webhook Error: ${err.message}`);
      res.status(400).send(`Webhook Error: ${err.message}`);
    }
  },
);

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

Go

package main

import (
  "context"
  "io"
  "log/slog"
  "net/http"
  "os"

  "github.com/stripe/stripe-go/v82"
)

func main() {
  apiKey := os.Getenv("STRIPE_API_KEY")
  webhookSecret := os.Getenv("STRIPE_WEBHOOK_SECRET")
  client := stripe.NewClient(apiKey)

  http.HandleFunc("/webhook", func(w http.ResponseWriter, req *http.Request) {
    defer req.Body.Close()
    payload, err := io.ReadAll(req.Body)
    if err != nil {
      slog.Error("Reading request body", "error", err)
      w.WriteHeader(http.StatusInternalServerError)
      return
    }
    thinEvent, err := client.ParseThinEvent(payload, req.Header.Get("Stripe-Signature"), webhookSecret)
    if err != nil {
      slog.Error("Parsing thin event", "error", err)
      w.WriteHeader(http.StatusInternalServerError)
      return
    }
    event, err := client.V2CoreEvents.Retrieve(context.TODO(), thinEvent.ID, nil)
    if err != nil {
      slog.Error("Retrieving snapshot event", "error", err)
      w.WriteHeader(http.StatusInternalServerError)
      return
    }

    switch e := event.(type) {
    case *stripe.V1BillingMeterErrorReportTriggeredEvent:
      meter, err := e.FetchRelatedObject()
      if err != nil {
        slog.Error("Error fetching related object", "error", err)
        w.WriteHeader(http.StatusInternalServerError)
        return
      }
      meterID := meter.ID
      // Add your logic here
    }

    w.WriteHeader(http.StatusOK)
  })
  err := http.ListenAndServe(":4242", nil)
  if err != nil {
    slog.Error("Starting server", "error", err)
    os.Exit(1)
  }
}

.NET

using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Stripe;
using Stripe.Events;
[Route("api/[controller]")]
[ApiController]
public class WebhookController : ControllerBase
{
    private readonly StripeClient _client;
    private readonly string _webhookSecret;
    public WebhookController()
    {
        var apiKey = Environment.GetEnvironmentVariable("STRIPE_API_KEY");
        _client = new StripeClient(apiKey);
        _webhookSecret = Environment.GetEnvironmentVariable("WEBHOOK_SECRET");
    }
    [HttpPost]
    public async Task<IActionResult> Index()
    {
        var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();
        try
        {
            var thinEvent = _client.ParseThinEvent(json, Request.Headers["Stripe-Signature"], _webhookSecret);
            // Fetch the event data to understand the failure
            var baseEvent = await _client.V2.Core.Events.GetAsync(thinEvent.Id);
            if (baseEvent is V1BillingMeterErrorReportTriggeredEvent fullEvent)
            {
                var meter = await fullEvent.FetchRelatedObjectAsync();
                var meterId = meter.Id;
                // Record the failures and alert your team
                // Add your logic here
            }
            return Ok();
        }
        catch (StripeException e)
        {
            return BadRequest(e.Message);
        }
    }
}

Using context

Snapshot events

This code snippet is a webhook function configured to check for received events, detect the originating account if applicable, handle the event, and return a 200 response.

Ruby

require 'json'

# Using Sinatra
post '/webhook' do
  payload = request.body.read
  event = nil

  begin
    event = Stripe::Event.construct_from(
      JSON.parse(payload, symbolize_names: true)
    )
  rescue JSON::ParserError => e
    # Invalid payload
    status 400
    return
  end

  # Extract the context
  context = event.context

  # Define your API key variables (ideally loaded securely)
  ACCOUNT_123_API_KEY = "sk_test_123"
  ACCOUNT_456_API_KEY = "sk_test_456"

  account_api_keys = {
    "account_123" => ACCOUNT_123_API_KEY,
    "account_456" => ACCOUNT_456_API_KEY
  }

  api_key = account_api_keys[context]

  if api_key.nil?
    puts "No API key found for context: #{context}"
    status 400
    return
  end

  # Handle the event
  case event.type
  when 'customer.created'
    customer = event.data.object

    begin
      latest_customer = Stripe::Customer.retrieve(
        customer.id,
        { api_key: api_key }
      )
      handle_customer_created(latest_customer, context)
    rescue => e
      puts "Error retrieving customer: #{e.message}"
      status 500
      return
    end

  when 'payment_method.attached'
    payment_method = event.data.object

    begin
      latest_payment_method = Stripe::PaymentMethod.retrieve(
        payment_method.id,
        { api_key: api_key }
      )
      handle_payment_method_attached(latest_payment_method, context)
    rescue => e
      puts "Error retrieving payment method: #{e.message}"
      status 500
      return
    end

  else
    puts "Unhandled event type: #{event.type}"
  end

  status 200
end

Python

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

# Define API key variables (in production, pull these from environment variables or secret manager)
ACCOUNT_123_API_KEY = "sk_test_123"
ACCOUNT_456_API_KEY = "sk_test_456"

account_api_keys = {
    "account_123": ACCOUNT_123_API_KEY,
    "account_456": ACCOUNT_456_API_KEY,
}

@csrf_exempt
def my_webhook_view(request):
    payload = request.body
    event = None

    try:
        event = stripe.Event.construct_from(
            json.loads(payload.decode('utf-8')), stripe.api_key
        )
    except ValueError as e:
        # Invalid payload
        return HttpResponse(status=400)

    # Extract context
    context = getattr(event, "context", None)
    if context is None:
        print("Missing context in event.")
        return HttpResponse(status=400)

    api_key = account_api_keys.get(context)
    if api_key is None:
        print(f"No API key found for context: {context}")
        return HttpResponse(status=400)

    # Handle the event
    if event.type == 'customer.created':
        customer = event.data.object
        try:
            latest_customer = stripe.Customer.retrieve(customer.id, api_key=api_key)
            handle_customer_created(latest_customer, context)
        except Exception as e:
            print(f"Error retrieving customer: {e}")
            return HttpResponse(status=500)

    elif event.type == 'payment_method.attached':
        payment_method = event.data.object
        try:
            latest_payment_method = stripe.PaymentMethod.retrieve(payment_method.id, api_key=api_key)
            handle_payment_method_attached(latest_payment_method, context)
        except Exception as e:
            print(f"Error retrieving payment method: {e}")
            return HttpResponse(status=500)

    else:
        print(f'Unhandled event type {event.type}')

    return HttpResponse(status=200)

Java

// Using the Spark framework
public Object handle(Request request, Response response) {
  String payload = request.body();
  Event event = null;

  try {
    event = ApiResource.GSON.fromJson(payload, Event.class);
  } catch (JsonSyntaxException e) {
    // Invalid payload
    response.status(400);
    return "";
  }

  // Get context from event
  String context = event.getContext();
  if (context == null || context.isEmpty()) {
    System.out.println("Missing context in event.");
    response.status(400);
    return "";
  }

  // Define your API key variables (in production, pull from environment or secrets manager)
  final String ACCOUNT_123_API_KEY = "sk_test_123";
  final String ACCOUNT_456_API_KEY = "sk_test_456";

  Map<String, String> accountApiKeys = new HashMap<>();
  accountApiKeys.put("account_123", ACCOUNT_123_API_KEY);
  accountApiKeys.put("account_456", ACCOUNT_456_API_KEY);

  String apiKey = accountApiKeys.get(context);
  if (apiKey == null) {
    System.out.println("No API key found for context: " + context);
    response.status(400);
    return "";
  }

  // Deserialize the nested object inside the event
  EventDataObjectDeserializer dataObjectDeserializer = event.getDataObjectDeserializer();
  if (!dataObjectDeserializer.getObject().isPresent()) {
    System.out.println("Unable to deserialize object from event.");
    response.status(400);
    return "";
  }

  StripeObject stripeObject = dataObjectDeserializer.getObject().get();

  // Set up RequestOptions with the correct API key
  RequestOptions requestOptions = RequestOptions.builder()
    .setApiKey(apiKey)
    .build();

  try {
    switch (event.getType()) {
      case "customer.created":
        Customer customerEvent = (Customer) stripeObject;
        // Fetch the latest Customer from Stripe using the account's API key
        Customer latestCustomer = Customer.retrieve(customerEvent.getId(), requestOptions);
        handleCustomerCreated(latestCustomer, context);
        break;

      case "payment_method.attached":
        PaymentMethod paymentMethodEvent = (PaymentMethod) stripeObject;
        // Fetch the latest PaymentMethod from Stripe using the account's API key
        PaymentMethod latestPaymentMethod = PaymentMethod.retrieve(paymentMethodEvent.getId(), requestOptions);
        handlePaymentMethodAttached(latestPaymentMethod, context);
        break;

      // ... handle other event types

      default:
        System.out.println("Unhandled event type: " + event.getType());
    }
  } catch (StripeException e) {
    System.out.println("Stripe API error: " + e.getMessage());
    response.status(500);
    return "";
  }

  response.status(200);
  return "";
}

Node.js

// This example uses Express to receive webhooks
const express = require('express');
const app = express();

app.use(express.json({ type: 'application/json' }));

// Define your API key variables (in production, load from environment variables or secrets)
const ACCOUNT_123_API_KEY = 'sk_test_123';
const ACCOUNT_456_API_KEY = 'sk_test_456';

const accountApiKeys = {
  account_123: ACCOUNT_123_API_KEY,
  account_456: ACCOUNT_456_API_KEY,
};

app.post('/webhook', async (request, response) => {
  const event = request.body;

  const context = event.context;
  if (!context) {
    console.error('Missing context in event');
    return response.status(400).send('Missing context');
  }

  const apiKey = accountApiKeys[context];
  if (!apiKey) {
    console.error(`No API key found for context: ${context}`);
    return response.status(400).send('Unknown context');
  }

  const stripe = Stripe(apiKey);

  try {
    switch (event.type) {
      case 'customer.created': {
        const customer = event.data.object;
        const latestCustomer = await stripe.customers.retrieve(customer.id);
        handleCustomerCreated(latestCustomer, context);
        break;
      }
      case 'payment_method.attached': {
        const paymentMethod = event.data.object;
        const latestPaymentMethod = await stripe.paymentMethods.retrieve(paymentMethod.id);
        handlePaymentMethodAttached(latestPaymentMethod, context);
        break;
      }
      // ... handle other event types
      default:
        console.log(`Unhandled event type ${event.type}`);
    }

    response.json({ received: true });
  } catch (err) {
    console.error(`Error processing event: ${err.message}`);
    response.status(500).send('Internal error');
  }
});

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

.NET

using System;
using System.IO;
using Microsoft.AspNetCore.Mvc;
using Stripe;

namespace workspace.Controllers
{
    [Route("api/[controller]")]
    public class StripeWebHook : Controller
    {
        // Define your API key variables (these should ideally come from secure config or env vars)
        private const string ACCOUNT_123_API_KEY = "sk_test_123";
        private const string ACCOUNT_456_API_KEY = "sk_test_456";

        private readonly Dictionary<string, string> accountApiKeys = new()
        {
            { "account_123", ACCOUNT_123_API_KEY },
            { "account_456", ACCOUNT_456_API_KEY }
        };

        [HttpPost]
        public async Task<IActionResult> Index()
        {
            var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();

            try
            {
                var stripeEvent = EventUtility.ParseEvent(json);
                var context = stripeEvent.Context;

                if (string.IsNullOrEmpty(context))
                {
                    Console.WriteLine("Missing context in event");
                    return BadRequest();
                }

                if (!accountApiKeys.TryGetValue(context, out var apiKey))
                {
                    Console.WriteLine($"No API key found for context: {context}");
                    return BadRequest();
                }

                var requestOptions = new RequestOptions
                {
                    ApiKey = apiKey
                };

                // Handle the event
                if (stripeEvent.Type == Events.CustomerCreated)
                {
                    var customerEvent = stripeEvent.Data.Object as Customer;
                    if (customerEvent != null)
                    {
                        var customerService = new CustomerService();
                        var latestCustomer = await customerService.GetAsync(customerEvent.Id, null, requestOptions);
                        HandleCustomerCreated(latestCustomer, context);
                    }
                }
                else if (stripeEvent.Type == Events.PaymentMethodAttached)
                {
                    var paymentMethodEvent = stripeEvent.Data.Object as PaymentMethod;
                    if (paymentMethodEvent != null)
                    {
                        var paymentMethodService = new PaymentMethodService();
                        var latestPaymentMethod = await paymentMethodService.GetAsync(paymentMethodEvent.Id, null, requestOptions);
                        HandlePaymentMethodAttached(latestPaymentMethod, context);
                    }
                }
                else
                {
                    Console.WriteLine("Unhandled event type: {0}", stripeEvent.Type);
                }

                return Ok();
            }
            catch (StripeException e)
            {
                Console.WriteLine($"Stripe error: {e.Message}");
                return BadRequest();
            }
        }

        private void HandleCustomerCreated(Customer customer, string context)
        {
            Console.WriteLine($"Handled customer {customer.Id} for context {context}");
            // Your custom logic here
        }

        private void HandlePaymentMethodAttached(PaymentMethod paymentMethod, string context)
        {
            Console.WriteLine($"Handled payment method {paymentMethod.Id} for context {context}");
            // Your custom logic here
        }
    }
}

Thin event handler (Clover+)

Use the EventNotifications context property to identify the account for events within your organization. You must set the Stripe-Context header manually for all API calls except .fetchRelatedObject() and .fetchEvent(), which do this for you automatically.

Python

org_api_key = os.environ.get("STRIPE_API_KEY")
webhook_secret = os.environ.get("WEBHOOK_SECRET")
client = StripeClient(org_api_key)

# inside your webhook handler
event_notification = client.parse_event_notification(payload, sig_header, webhook_secret)

# uses `context` automatically
event_notification.fetch_event()

# pass context manually for other API requests
client.v1.invoices.list(stripe_context=event_notification.context)

Ruby

api_key = ENV.fetch("STRIPE_API_KEY", nil)
webhook_secret = ENV.fetch("WEBHOOK_SECRET", nil)
client = Stripe::StripeClient.new(api_key)

# inside your webhook handler
event_notification = client.parse_event_notification(payload, sig_header, webhook_secret)

# uses `context` automatically
event_notification.fetch_event

# pass context manually for other API requests
client.v1.invoices.list(nil, { stripe_context: event_notification.context })

Typescript

const orgApiKey = process.env.STRIPE_API_KEY;
const webhookSecret = process.env.WEBHOOK_SECRET;
const client = new Stripe(orgApiKey);

// inside your webhook handler
const eventNotification = client.parseEventNotification(
  req.body,
  sig,
  webhookSecret
);

// uses `context` automatically:
await eventNotification.fetchEvent()

// pass context manually for other reuqests:
client.invoices.list(undefined, {
  stripeContext: eventNotification.context,
});

Java

String orgApiKey = System.getenv("STRIPE_API_KEY");
String webhookSecret = System.getenv("WEBHOOK_SECRET");
StripeClient client = new StripeClient(orgApiKey);

// inside your webhook handler
EventNotification notif =
              client.parseEventNotification(webhookBody, sigHeader, WEBHOOK_SECRET);

// cast to a more specific type
V1BillingMeterErrorReportTriggeredEventNotification eventNotification =
      (V1BillingMeterErrorReportTriggeredEventNotification) notif;

// uses `context` automatically
eventNotification.fetchEvent();

// pass context manually for other API requests
client
    .v1()
    .invoices()
    .list(
        new RequestOptions.RequestOptionsBuilder()
            .setStripeContext(eventNotification.context)
            .build());

PHP

$org_api_key = getenv('STRIPE_API_KEY');
$webhook_secret = getenv('WEBHOOK_SECRET');
$client = new \Stripe\StripeClient($org_api_key);

// inside your webhook handler
$event_notification = $client->parseEventNotification($webhook_body, $sig_header, $webhook_secret);

// uses context automatically
$event_notification->fetchEvent();

// pass context manually for other API requests
$client->invoices->all(null, ["stripe_context" => $event_notification->context]);

Go

orgApiKey := os.Getenv("STRIPE_API_KEY")
webhookSecret := os.Getenv("WEBHOOK_SECRET")
client := stripe.NewClient(orgApiKey)

// inside your webhook handler
eventNotification, err := client.ParseEventNotification(payload, req.Header.Get("Stripe-Signature"), webhookSecret)
if err != nil {
  fmt.Fprintf(os.Stderr, "Error reading request body: %v\n", err)
  w.WriteHeader(http.StatusInternalServerError)
  return
}

// cast to a more specific type
switch evt := eventNotification.(type) {
case *stripe.V1BillingMeterErrorReportTriggeredEventNotification:
  // sets `Stripe-Context` automatically
  evt.FetchEvent(context.TODO())


  // pass context manually for other API requests
  client.V1Invoices.Retrieve(context.TODO(), "inv_123", &stripe.InvoiceRetrieveParams{
    Params: stripe.Params{
      StripeContext: evt.Context.StringPtr(),
    },
  })
}

.NET

_client = new StripeClient(Environment.GetEnvironmentVariable("STRIPE_API_KEY"));
_webhookSecret = Environment.GetEnvironmentVariable("WEBHOOK_SECRET");

// inside your webhook handler
var eventNotification = client.ParseEventNotification(json, Request.Headers["Stripe-Signature"], webhookSecret);

if (eventNotification is V1BillingMeterErrorReportTriggeredEventNotification notif)
{
  // uses `context` automatically
  notif.fetchEvent();

  // pass context manually for other API requests
  client.V1.Invoices.List(null, new RequestOptions
  {
      StripeContext = notif.Context,
  });
}

Thin event handler (Acacia or Basil)

This code snippet is a webhook function configured to receive thin events across an organization, verify the signature, determine the originating account with the context field, and use that accounts API key for subsequent API calls.

Python

import os
from flask import Flask, request, jsonify
from stripe import StripeClient
from stripe.events import V1BillingMeterErrorReportTriggeredEvent

app = Flask(__name__)

org_api_key = os.environ.get("STRIPE_API_KEY")
webhook_secret = os.environ.get("WEBHOOK_SECRET")
client = StripeClient(org_api_key)

account_api_keys = {
    "account_123": os.environ.get("ACCOUNT_123_API_KEY"),
    "account_456": os.environ.get("ACCOUNT_456_API_KEY"),
}

@app.route("/webhook", methods=["POST"])
def webhook():
    payload = request.data
    sig_header = request.headers.get("Stripe-Signature")

    try:
        thin_event = client.parse_thin_event(payload, sig_header, webhook_secret)

        # Retrieve the event using the org client to inspect context
        event = client.v2.core.events.retrieve(thin_event.id)

        context = getattr(event, "context", None)
        if not context:
            return jsonify(error="Missing context"), 400

        account_key = account_api_keys.get(context)
        if not account_key:
            return jsonify(error="Unknown context"), 400

        account_client = StripeClient(account_key)
        full_event = account_client.v2.core.events.retrieve(thin_event.id)

        if isinstance(full_event, V1BillingMeterErrorReportTriggeredEvent):
            meter = full_event.fetch_related_object()
            meter_id = meter.id
            # Record the failures and alert your team
            # Add your logic here

        return jsonify(success=True), 200
    except Exception as e:
        return jsonify(error=str(e)), 400

if __name__ == "__main__":
    app.run(port=4242)

Ruby

require "stripe"
require "sinatra"

api_key = ENV.fetch("STRIPE_API_KEY", nil)
webhook_secret = ENV.fetch("WEBHOOK_SECRET", nil)
client = Stripe::StripeClient.new(api_key)

account_api_keys = {
  "account_123" => ENV["ACCOUNT_123_API_KEY"],
  "account_456" => ENV["ACCOUNT_456_API_KEY"],
}

post "/webhook" do
  webhook_body = request.body.read
  sig_header = request.env["HTTP_STRIPE_SIGNATURE"]

  begin
    thin_event = client.parse_thin_event(webhook_body, sig_header, webhook_secret)
    event = client.v2.core.events.retrieve(thin_event.id)

    context = event.context
    halt 400 if context.nil?

    account_key = account_api_keys[context]
    halt 400 if account_key.nil?

    account_client = Stripe::StripeClient.new(account_key)
    full_event = account_client.v2.core.events.retrieve(thin_event.id)

    if full_event.instance_of? Stripe::V1BillingMeterErrorReportTriggeredEvent
      meter = full_event.fetch_related_object
      # Record the failures and alert your team
      # Add your logic here
    end

    status 200
  rescue => e
    status 400
  end
end

Node.js

const express = require('express');
const {Stripe} = require('stripe');

const app = express();

const apiKey = process.env.STRIPE_API_KEY;
const webhookSecret = process.env.WEBHOOK_SECRET;
const client = new Stripe(apiKey);

const accountApiKeys = {
  account_123: process.env.ACCOUNT_123_API_KEY,
  account_456: process.env.ACCOUNT_456_API_KEY,
};

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

  try {
    const thinEvent = client.parseThinEvent(req.body, sig, webhookSecret);
    const event = await client.v2.core.events.retrieve(thinEvent.id);

    const context = event.context;
    if (!context) return res.status(400).send('Missing context');

    const accountKey = accountApiKeys[context];
    if (!accountKey) return res.status(400).send('Unknown context');

    const accountClient = new Stripe(accountKey);
    const fullEvent = await accountClient.v2.core.events.retrieve(thinEvent.id);

    if (fullEvent.type === 'v1.billing.meter.error_report_triggered') {
      const meter = await fullEvent.fetchRelatedObject();
      // Record the failures and alert your team
      // Add your logic here
    }

    res.sendStatus(200);
  } catch (err) {
    res.status(400).send(`Webhook Error: ${err.message}`);
  }
});

app.listen(4242);

Java

import com.stripe.StripeClient;
import com.stripe.events.V1BillingMeterErrorReportTriggeredEvent;
import com.stripe.model.Event;
import com.stripe.model.ThinEvent;
import java.util.HashMap;
import java.util.Map;

public Object handle(Request request, Response response) {
  String apiKey = System.getenv("STRIPE_API_KEY");
  String webhookSecret = System.getenv("WEBHOOK_SECRET");
  StripeClient client = new StripeClient(apiKey);

  Map<String, String> accountApiKeys = new HashMap<>();
  accountApiKeys.put("account_123", System.getenv("ACCOUNT_123_API_KEY"));
  accountApiKeys.put("account_456", System.getenv("ACCOUNT_456_API_KEY"));

  try {
    String webhookBody = request.body();
    String sigHeader = request.headers("Stripe-Signature");
    ThinEvent thinEvent = client.parseThinEvent(webhookBody, sigHeader, webhookSecret);

    Event baseEvent = client.v2().core().events().retrieve(thinEvent.getId());
    String context = baseEvent.getContext();
    if (context == null || context.isEmpty()) {
      response.status(400);
      return "";
    }

    String accountKey = accountApiKeys.get(context);
    if (accountKey == null || accountKey.isEmpty()) {
      response.status(400);
      return "";
    }

    StripeClient accountClient = new StripeClient(accountKey);
    Event fullEvent = accountClient.v2().core().events().retrieve(thinEvent.getId());

    if (fullEvent instanceof V1BillingMeterErrorReportTriggeredEvent) {
      V1BillingMeterErrorReportTriggeredEvent ev = (V1BillingMeterErrorReportTriggeredEvent) fullEvent;
      Object meter = ev.fetchRelatedObject();
      // Record the failures and alert your team
      // Add your logic here
    }

    response.status(200);
    return "";
  } catch (Exception e) {
    response.status(400);
    return "";
  }
}

PHP

<?php

require 'vendor/autoload.php';

$api_key = getenv('STRIPE_API_KEY');
$webhook_secret = getenv('WEBHOOK_SECRET');
$client = new \Stripe\StripeClient($api_key);

$accountApiKeys = [
  'account_123' => getenv('ACCOUNT_123_API_KEY'),
  'account_456' => getenv('ACCOUNT_456_API_KEY'),
];

$app = new \Slim\App();

$app->post('/webhook', function ($request, $response) use ($client, $webhook_secret, $accountApiKeys) {
    $webhook_body = $request->getBody()->getContents();
    $sig_header = $request->getHeaderLine('Stripe-Signature');

    try {
        $thin_event = $client->parseThinEvent($webhook_body, $sig_header, $webhook_secret);
        $event = $client->v2->core->events->retrieve($thin_event->id);

        $context = $event->context ?? null;
        if (!$context) return $response->withStatus(400);

        $accountKey = $accountApiKeys[$context] ?? null;
        if (!$accountKey) return $response->withStatus(400);

        $accountClient = new \Stripe\StripeClient($accountKey);
        $full_event = $accountClient->v2->core->events->retrieve($thin_event->id);

        if ($full_event instanceof \Stripe\\Events\\V1BillingMeterErrorReportTriggeredEvent) {
            $meter = $full_event->fetchRelatedObject();
            // Record the failures and alert your team
            // Add your logic here
        }

        return $response->withStatus(200);
    } catch (\Exception $e) {
        return $response->withStatus(400);
    }
});

$app->run();

Go

package main

import (
  "io"
  "log/slog"
  "net/http"
  "os"

  "github.com/stripe/stripe-go/v82"
)

func main() {
  apiKey := os.Getenv("STRIPE_API_KEY")
  webhookSecret := os.Getenv("WEBHOOK_SECRET")
  client := stripe.NewClient(apiKey)

  accountApiKeys := map[string]string{
    "account_123": os.Getenv("ACCOUNT_123_API_KEY"),
    "account_456": os.Getenv("ACCOUNT_456_API_KEY"),
  }

  http.HandleFunc("/webhook", func(w http.ResponseWriter, req *http.Request) {
    defer req.Body.Close()
    payload, err := io.ReadAll(req.Body)
    if err != nil {
      slog.Error("read body", "error", err)
      w.WriteHeader(http.StatusInternalServerError)
      return
    }

    thinEvent, err := client.ParseThinEvent(payload, req.Header.Get("Stripe-Signature"), webhookSecret)
    if err != nil {
      w.WriteHeader(http.StatusBadRequest)
      return
    }

    baseEvent, err := client.V2.Core.Events.Retrieve(thinEvent.ID)
    if err != nil {
      w.WriteHeader(http.StatusInternalServerError)
      return
    }

    if baseEvent.Context == "" {
      w.WriteHeader(http.StatusBadRequest)
      return
    }

    accountKey, ok := accountApiKeys[baseEvent.Context]
    if !ok || accountKey == "" {
      w.WriteHeader(http.StatusBadRequest)
      return
    }

    accountClient := stripe.NewClient(accountKey)
    fullEvent, err := accountClient.V2.Core.Events.Retrieve(thinEvent.ID)
    if err != nil {
      w.WriteHeader(http.StatusInternalServerError)
      return
    }

    switch e := fullEvent.(type) {
    case *stripe.V1BillingMeterErrorReportTriggeredEvent:
      meter, err := e.FetchRelatedObject()
      if err == nil {
        _ = meter
        // Record the failures and alert your team
        // Add your logic here
      }
    }

    w.WriteHeader(http.StatusOK)
  })

  http.ListenAndServe(":4242", nil)
}

.NET

using Microsoft.AspNetCore.Mvc;
using Stripe;

[ApiController]
[Route("/webhook")]
public class WebhookController : ControllerBase
{
    private readonly StripeClient _client;
    private readonly string _webhookSecret;
    private readonly Dictionary<string, string> _accountApiKeys;

    public WebhookController()
    {
        _client = new StripeClient(Environment.GetEnvironmentVariable("STRIPE_API_KEY"));
        _webhookSecret = Environment.GetEnvironmentVariable("WEBHOOK_SECRET");
        _accountApiKeys = new Dictionary<string, string>
        {
            { "account_123", Environment.GetEnvironmentVariable("ACCOUNT_123_API_KEY") },
            { "account_456", Environment.GetEnvironmentVariable("ACCOUNT_456_API_KEY") },
        };
    }

    [HttpPost]
    public async Task<IActionResult> Handle()
    {
        using var reader = new StreamReader(Request.Body);
        var json = await reader.ReadToEndAsync();

        try
        {
            var thinEvent = _client.ParseThinEvent(json, Request.Headers["Stripe-Signature"], _webhookSecret);
            var baseEvent = await _client.V2.Core.Events.GetAsync(thinEvent.Id);

            if (string.IsNullOrEmpty(baseEvent.Context))
            {
                return BadRequest();
            }

            if (!_accountApiKeys.TryGetValue(baseEvent.Context, out var accountKey) || string.IsNullOrEmpty(accountKey))
            {
                return BadRequest();
            }

            var accountClient = new StripeClient(accountKey);
            var fullEvent = await accountClient.V2.Core.Events.GetAsync(thinEvent.Id);

            if (fullEvent is V1BillingMeterErrorReportTriggeredEvent ev)
            {
                var meter = await ev.FetchRelatedObjectAsync();
                // Record the failures and alert your team
                // Add your logic here
            }

            return Ok();
        }
        catch
        {
            return BadRequest();
        }
    }
}

Test your handler

Before you go-live with your webhook endpoint function, we recommend that you test your application integration. You can do so by configuring a local listener to send events to your local machine, and sending test events. You need to use the CLI to test.

Forward events to a local endpoint

To forward events to your local endpoint, run the following command with the CLI to set up a local listener. The --forward-to flag sends all Stripe events in a sandbox to your local webhook endpoint. Use the appropriate CLI commands below depending on whether you use thin or snapshot events.

Forward snapshot events

Use the following command to forward snapshot events to your local listener.

stripe listen --forward-to localhost:4242

Forward thin events

Use the following command to forward thin events to your local listener.

$ stripe listen --forward-thin-to localhost:4242 --thin-events "*"

You can also run stripe listen to see events in Stripe Shell, although you wont be able to forward events from the shell to your local endpoint.

Useful configurations to help you test with your local listener include the following:

  • To disable HTTPS certificate verification, use the --skip-verify optional flag.
  • To forward only specific events, use the --events optional flag and pass in a comma separated list of events.

Forward target snapshot events

Use the following command to forward target snapshot events to your local listener.

stripe listen --events payment_intent.created,customer.created,payment_intent.succeeded,checkout.session.completed,payment_intent.payment_failed \
  --forward-to localhost:4242

Forward target thin events

Use the following command to forward target thin events to your local listener.

stripe listen --thin-events v1.billing.meter.error_report_triggered,v1.billing.meter.no_meter_found \
  --forward-thin-to localhost:4242
  • To forward events to your local webhook endpoint from the public webhook endpoint that you already registered on Stripe, use the --load-from-webhooks-api optional flag. It loads your registered endpoint, parses the path and its registered events, then appends the path to your local webhook endpoint in the --forward-to path.

Forward snapshot events from a public webhook endpoint

Use the following command to forward snapshot events from a public webhook endpoint to your local listener.

stripe listen --load-from-webhooks-api --forward-to localhost:4242

Forward thin events from a public webhook endpoint

Use the following command to forward thin events from a public webhook endpoint to your local listener.

stripe listen --load-from-webhooks-api --forward-thin-to localhost:4242
  • To check webhook signatures, use the {{WEBHOOK_SIGNING_SECRET}} from the initial output of the listen command.
Ready! Your webhook signing secret is '{{WEBHOOK_SIGNING_SECRET}}' (^C to quit)

Triggering test events

To send test events, trigger an event type that your event destination is subscribed to by manually creating an object in the Stripe Dashboard. Learn how to trigger events with Stripe for VS Code.

Trigger a snapshot event

You can use the following command in either Stripe Shell or Stripe CLI. This example triggers a payment_intent.succeeded event:

stripe trigger payment_intent.succeeded
Running fixture for: payment_intent
Trigger succeeded! Check dashboard for event details.

Trigger a thin event

You can use the following command in the Stripe CLI. This example triggers a outbound_payment.posted event:

stripe preview trigger outbound_payment.posted
Setting up fixture for: finaddr_info
Running fixture for: finaddr_info
Setting up fixture for: create_recipient
Running fixture for: create_recipient
Setting up fixture for: create_destination
Running fixture for: create_destination
Setting up fixture for: create_outbound_payment
Running fixture for: create_outbound_payment

Register your endpoint

After testing your webhook endpoint function, use the API or the Webhooks tab in Workbench to register your webhook endpoints accessible URL so Stripe knows where to deliver events. You can register up to 16 webhook endpoints with Stripe. Registered webhook endpoints must be publicly accessible HTTPS URLs.

Webhook URL format

The URL format to register a webhook endpoint is:

https://<your-website>/<your-webhook-endpoint>

For example, if your domain is https://mycompanysite.com and the route to your webhook endpoint is @app.route('/stripe_webhooks', methods=['POST']), specify https://mycompanysite.com/stripe_webhooks as the Endpoint URL.

Create an event destination for your webhook endpoint

Create an event destination using Workbench in the Dashboard or programmatically with the API. You can register up to 16 event destinations on each Stripe account.

Dashboard

To create a new webhook endpoint in the Dashboard:

  1. Open the Webhooks tab in Workbench.
  2. Click Create an event destination.
  3. Select where you want to receive events from. Stripe supports two types of configurations: Your account and Connected accounts. Select Account to listen to events from your own account. If you created a Connect application and want to listen to events from your connected accounts, select Connected accounts.

Listen to events from an organization webhook endpoint

If you create a webhook endpoint in an organization account, select Accounts to listen to events from accounts in your organization. If you have Connect platforms as members of your organizations and want to listen to events from the all the platforms connected accounts, select Connected accounts.

  1. Select the API version for the events object you want to consume.
  2. Select the event types that you want to send to a webhook endpoint.
  3. Select Continue, then select Webhook endpoint as the destination type.
  4. Click Continue, then provide the Endpoint URL and an optional description for the webhook. Register a new webhook using the Webhooks tab

Register a new webhook using the Webhooks tab

API

You can create a new event destination that notifies you when a usage-based billing validation error is triggered using the API.

If youve created a Connect application and want to listen to your connected accounts, use the events_from parameter and set its enum value to accounts.

curl -X POST https://api.stripe.com/v2/core/event_destinations \
  -H "Authorization: Bearer <<YOUR_SECRET_KEY>>" \
  -H "Stripe-Version: {{STRIPE_API_VERSION}}" \
  --json '{
    "name": "My event destination",
    "description": "This is my event destination, I like it a lot",
    "type": "webhook_endpoint",
    "event_payload": "thin",
    "enabled_events": [
        "v1.billing.meter.error_report_triggered"
    ],
    "webhook_endpoint": {
        "url": "https://example.com/my/webhook/endpoint"
    }
  }'
stripe v2 core event_destinations create  \
  --name="My event destination" \
  --description="This is my event destination, I like it a lot" \
  --type=webhook_endpoint \
  --event-payload=thin \
  --enabled-events="v1.billing.meter.error_report_triggered" \
  --webhook-endpoint.url="https://example.com/my/webhook/endpoint"
# Set your secret key. Remember to switch to your live secret key in production.
# See your keys here: https://dashboard.stripe.com/apikeys
client = Stripe::StripeClient.new("<<YOUR_SECRET_KEY>>")

event_destination = client.v2.core.event_destinations.create({
  name: 'My event destination',
  description: 'This is my event destination, I like it a lot',
  type: 'webhook_endpoint',
  event_payload: 'thin',
  enabled_events: ['v1.billing.meter.error_report_triggered'],
  webhook_endpoint: {url: 'https://example.com/my/webhook/endpoint'},
})
# Set your secret key. Remember to switch to your live secret key in production.
# See your keys here: https://dashboard.stripe.com/apikeys
client = StripeClient("<<YOUR_SECRET_KEY>>")

event_destination = client.v2.core.event_destinations.create({
  "name": "My event destination",
  "description": "This is my event destination, I like it a lot",
  "type": "webhook_endpoint",
  "event_payload": "thin",
  "enabled_events": ["v1.billing.meter.error_report_triggered"],
  "webhook_endpoint": {"url": "https://example.com/my/webhook/endpoint"},
})
// Set your secret key. Remember to switch to your live secret key in production.
// See your keys here: https://dashboard.stripe.com/apikeys
$stripe = new \Stripe\StripeClient('<<YOUR_SECRET_KEY>>');

$eventDestination = $stripe->v2->core->eventDestinations->create([
  'name' => 'My event destination',
  'description' => 'This is my event destination, I like it a lot',
  'type' => 'webhook_endpoint',
  'event_payload' => 'thin',
  'enabled_events' => ['v1.billing.meter.error_report_triggered'],
  'webhook_endpoint' => ['url' => 'https://example.com/my/webhook/endpoint'],
]);
// Set your secret key. Remember to switch to your live secret key in production.
// See your keys here: https://dashboard.stripe.com/apikeys
StripeClient client = new StripeClient("<<YOUR_SECRET_KEY>>");

EventDestinationCreateParams params =
  EventDestinationCreateParams.builder()
    .setName("My event destination")
    .setDescription("This is my event destination, I like it a lot")
    .setType(EventDestinationCreateParams.Type.WEBHOOK_ENDPOINT)
    .setEventPayload(EventDestinationCreateParams.EventPayload.THIN)
    .addEnabledEvent("v1.billing.meter.error_report_triggered")
    .setWebhookEndpoint(
      EventDestinationCreateParams.WebhookEndpoint.builder()
        .setUrl("https://example.com/my/webhook/endpoint")
        .build()
    )
    .build();

EventDestination eventDestination = client.v2().core().eventDestinations().create(params);
// Set your secret key. Remember to switch to your live secret key in production.
// See your keys here: https://dashboard.stripe.com/apikeys
const stripe = require('stripe')('<<YOUR_SECRET_KEY>>');

const eventDestination = await stripe.v2.core.eventDestinations.create({
  name: 'My event destination',
  description: 'This is my event destination, I like it a lot',
  type: 'webhook_endpoint',
  event_payload: 'thin',
  enabled_events: ['v1.billing.meter.error_report_triggered'],
  webhook_endpoint: {
    url: 'https://example.com/my/webhook/endpoint',
  },
});
// Set your secret key. Remember to switch to your live secret key in production.
// See your keys here: https://dashboard.stripe.com/apikeys
sc := stripe.NewClient("<<YOUR_SECRET_KEY>>")
params := &stripe.V2CoreEventDestinationCreateParams{
  Name: stripe.String("My event destination"),
  Description: stripe.String("This is my event destination, I like it a lot"),
  Type: stripe.String("webhook_endpoint"),
  EventPayload: stripe.String("thin"),
  EnabledEvents: []*string{stripe.String("v1.billing.meter.error_report_triggered")},
  WebhookEndpoint: &stripe.V2CoreEventDestinationCreateWebhookEndpointParams{
    URL: stripe.String("https://example.com/my/webhook/endpoint"),
  },
}
result, err := sc.V2CoreEventDestinations.Create(context.TODO(), params)
// Set your secret key. Remember to switch to your live secret key in production.
// See your keys here: https://dashboard.stripe.com/apikeys
var options = new Stripe.V2.Core.EventDestinationCreateOptions
{
    Name = "My event destination",
    Description = "This is my event destination, I like it a lot",
    Type = "webhook_endpoint",
    EventPayload = "thin",
    EnabledEvents = new List<string> { "v1.billing.meter.error_report_triggered" },
    WebhookEndpoint = new Stripe.V2.Core.EventDestinationCreateWebhookEndpointOptions
    {
        Url = "https://example.com/my/webhook/endpoint",
    },
};
var client = new StripeClient("<<YOUR_SECRET_KEY>>");
var service = client.V2.Core.EventDestinations;
Stripe.V2.Core.EventDestination eventDestination = service.Create(options);

Workbench replaces the existing Developers Dashboard. If youre still using the Developers Dashboard, see how to create a new webhook endpoint.

Secure your endpoint

After confirming that your endpoint works as expected, secure it by implementing webhook best practices.

You need to secure your integration by making sure your handler verifies that all webhook requests are generated by Stripe. You can verify webhook signatures using our official libraries or verify them manually.

Verify webhook signatures with official libraries

We recommend using our official libraries to verify signatures. You perform the verification by providing the event payload, the Stripe-Signature header, and the endpoints secret. If verification fails, you get an error.

If you get a signature verification error, read our guide about troubleshooting it.

Stripe requires the raw body of the request to perform signature verification. If youre using a framework, make sure it doesnt manipulate the raw body. Any manipulation to the raw body of the request causes the verification to fail.

Ruby


# Set your secret key. Remember to switch to your live secret key in production.
# See your keys here: https://dashboard.stripe.com/apikeys
Stripe.api_key = '<<YOUR_SECRET_KEY>>'

require 'stripe'
require 'sinatra'

# If you are testing your webhook locally with the Stripe CLI you
# can find the endpoint's secret by running `stripe listen`
# Otherwise, find your endpoint's secret in your webhook settings in
# the Developer Dashboardendpoint_secret = 'whsec_...'

# Using the Sinatra framework
set :port, 4242

post '/my/webhook/url' do
  payload = request.body.readsig_header = request.env['HTTP_STRIPE_SIGNATURE']
  event = nil

  beginevent = Stripe::Webhook.construct_event(
      payload, sig_header, endpoint_secret
    )
  rescue JSON::ParserError => e
    # Invalid payload
    puts "Error parsing payload: #{e.message}"
    status 400
    return
  rescue Stripe::SignatureVerificationError => e# Invalid signature
    puts "Error verifying webhook signature: #{e.message}"
    status 400
    return
  end

  # Handle the event
  case event.type
  when 'payment_intent.succeeded'
    payment_intent = event.data.object # contains a Stripe::PaymentIntent
    puts 'PaymentIntent was successful!'
  when 'payment_method.attached'
    payment_method = event.data.object # contains a Stripe::PaymentMethod
    puts 'PaymentMethod was attached to a Customer!'
  # ... handle other event types
  else
    puts "Unhandled event type: #{event.type}"
  end

  status 200
end

Python


# Set your secret key. Remember to switch to your live secret key in production.
# See your keys here: https://dashboard.stripe.com/apikeys
stripe.api_key = '<<YOUR_SECRET_KEY>>'

from django.http import HttpResponse

# If you are testing your webhook locally with the Stripe CLI you
# can find the endpoint's secret by running `stripe listen`
# Otherwise, find your endpoint's secret in your webhook settings in the Developer Dashboardendpoint_secret = 'whsec_...'

# Using Django
@csrf_exempt
def my_webhook_view(request):
  payload = request.bodysig_header = request.META['HTTP_STRIPE_SIGNATURE']
  event = None

  try:event = stripe.Webhook.construct_event(
      payload, sig_header, endpoint_secret
    )
  except ValueError as e:
    # Invalid payload
    print('Error parsing payload: {}'.format(str(e)))
    return HttpResponse(status=400)except stripe.error.SignatureVerificationError as e:
    # Invalid signature
    print('Error verifying webhook signature: {}'.format(str(e)))
    return HttpResponse(status=400)

  # Handle the event
  if event.type == 'payment_intent.succeeded':
    payment_intent = event.data.object # contains a stripe.PaymentIntent
    print('PaymentIntent was successful!')
  elif event.type == 'payment_method.attached':
    payment_method = event.data.object # contains a stripe.PaymentMethod
    print('PaymentMethod was attached to a Customer!')
  # ... handle other event types
  else:
    print('Unhandled event type {}'.format(event.type))

  return HttpResponse(status=200)

PHP


// Set your secret key. Remember to switch to your live secret key in production.
// See your keys here: https://dashboard.stripe.com/apikeys
\Stripe\Stripe::setApiKey('<<YOUR_SECRET_KEY>>');

// If you are testing your webhook locally with the Stripe CLI you
// can find the endpoint's secret by running `stripe listen`
// Otherwise, find your endpoint's secret in your webhook settings in the Developer Dashboard$endpoint_secret = 'whsec_...';

$payload = @file_get_contents('php://input');
$sig_header = $_SERVER['HTTP_STRIPE_SIGNATURE'];
$event = null;

try {$event = \Stripe\Webhook::constructEvent(
        $payload, $sig_header, $endpoint_secret
    );
} catch(\UnexpectedValueException $e) {
    // Invalid payload
  http_response_code(400);
  echo json_encode(['Error parsing payload: ' => $e->getMessage()]);
  exit();} catch(\Stripe\Exception\SignatureVerificationException $e) {
    // Invalid signature
    http_response_code(400);
    echo json_encode(['Error verifying webhook signature: ' => $e->getMessage()]);
    exit();
}

// Handle the event
switch ($event->type) {
    case 'payment_intent.succeeded':
        $paymentIntent = $event->data->object; // contains a \Stripe\PaymentIntent
        handlePaymentIntentSucceeded($paymentIntent);
        break;
    case 'payment_method.attached':
        $paymentMethod = $event->data->object; // contains a \Stripe\PaymentMethod
        handlePaymentMethodAttached($paymentMethod);
        break;
    // ... handle other event types
    default:
        echo 'Received unknown event type ' . $event->type;
}

http_response_code(200);

Java


// Set your secret key. Remember to switch to your live secret key in production.
// See your keys here: https://dashboard.stripe.com/apikeys
Stripe.apiKey = "<<YOUR_SECRET_KEY>>";

import com.stripe.Stripe;
import com.stripe.model.StripeObject;
import com.stripe.net.ApiResource;
import com.stripe.net.Webhook;
import com.stripe.model.Event;
import com.stripe.model.EventDataObjectDeserializer;
import com.stripe.model.PaymentIntent;
import com.stripe.exception.SignatureVerificationException;

// If you are testing your webhook locally with the Stripe CLI you
// can find the endpoint's secret by running `stripe listen`
// Otherwise, find your endpoint's secret in your webhook settings in the Developer DashboardString endpointSecret = "whsec_...";

// Using the Spark framework
public Object handle(Request request, Response response) {
  String payload = request.body();String sigHeader = request.headers("Stripe-Signature");
  Event event = null;

  try {event = Webhook.constructEvent(
      payload, sigHeader, endpointSecret
    );
  } catch (JsonSyntaxException e) {
    // Invalid payload
    System.out.println("Error parsing payload: " + e.getMessage());
    response.status(400);
    return gson.toJson(new ErrorResponse(e.getMessage()));} catch (SignatureVerificationException e) {
    // Invalid signature
    System.out.println("Error verifying webhook signature: " + e.getMessage());
    response.status(400);
    return gson.toJson(new ErrorResponse(e.getMessage()));
  }

  // Deserialize the nested object inside the event
  EventDataObjectDeserializer dataObjectDeserializer = event.getDataObjectDeserializer();
  StripeObject stripeObject = null;
  if (dataObjectDeserializer.getObject().isPresent()) {
    stripeObject = dataObjectDeserializer.getObject().get();
  } else {
    // Deserialization failed, probably due to an API version mismatch.
    // Refer to the Javadoc documentation on `EventDataObjectDeserializer` for
    // instructions on how to handle this case, or return an error here.
  }

  // Handle the event
  switch (event.getType()) {
    case "payment_intent.succeeded":
      PaymentIntent paymentIntent = (PaymentIntent) stripeObject;
      System.out.println("PaymentIntent was successful!");
      break;
    case "payment_method.attached":
      PaymentMethod paymentMethod = (PaymentMethod) stripeObject;
      System.out.println("PaymentMethod was attached to a Customer!");
      break;
    // ... handle other event types
    default:
      System.out.println("Unhandled event type: " + event.getType());
  }

  response.status(200);
  return "";
}

Node.js


// Set your secret key. Remember to switch to your live secret key in production.
// See your keys here: https://dashboard.stripe.com/apikeys
const stripe = require('stripe')('<<YOUR_SECRET_KEY>>');

// If you are testing your webhook locally with the Stripe CLI you
// can find the endpoint's secret by running `stripe listen`
// Otherwise, find your endpoint's secret in your webhook settings in the Developer Dashboardconst endpointSecret = 'whsec_...';

// This example uses Express to receive webhooks
const express = require('express');

const app = express();

// Match the raw body to content type application/json
app.post('/webhook', express.raw({type: 'application/json'}), (request, response) => {const sig = request.headers['stripe-signature'];

  let event;

  try {event = stripe.webhooks.constructEvent(request.body, sig, endpointSecret);
  }catch (err) {
    response.status(400).send(`Webhook Error: ${err.message}`);
  }

  // Handle the event
  switch (event.type) {
    case 'payment_intent.succeeded':
      const paymentIntent = event.data.object;
      console.log('PaymentIntent was successful!');
      break;
    case 'payment_method.attached':
      const paymentMethod = event.data.object;
      console.log('PaymentMethod was attached to a Customer!');
      break;
    // ... handle other event types
    default:
      console.log(`Unhandled event type ${event.type}`);
  }

  // Return a response to acknowledge receipt of the event
  response.json({received: true});
});

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

Go


// Set your secret key. Remember to switch to your live secret key in production.
// See your keys here: https://dashboard.stripe.com/apikeys
stripe.Key = "<<YOUR_SECRET_KEY>>"

http.HandleFunc("/webhook", func(w http.ResponseWriter, req *http.Request) {
    const MaxBodyBytes = int64(65536)
    req.Body = http.MaxBytesReader(w, req.Body, MaxBodyBytes)
    payload, err := ioutil.ReadAll(req.Body)
    if err != nil {
        fmt.Fprintf(os.Stderr, "Error reading request body: %v\n", err)
        w.WriteHeader(http.StatusServiceUnavailable)
        return
    }

    // If you are testing your webhook locally with the Stripe CLI you
    // can find the endpoint's secret by running `stripe listen`
    // Otherwise, find your endpoint's secret in your webhook settings
    // in the Developer DashboardendpointSecret := "whsec_...";

    // Pass the request body and Stripe-Signature header to ConstructEvent, along
    // with the webhook signing key.event, err := webhook.ConstructEvent(payload, req.Header.Get("Stripe-Signature"),
        endpointSecret)
if err != nil {
        fmt.Fprintf(os.Stderr, "Error verifying webhook signature: %v\n", err)
        w.WriteHeader(http.StatusBadRequest) // Return a 400 error on a bad signature
        return
    }

    // Unmarshal the event data into an appropriate struct depending on its Type
    switch event.Type {
    case "payment_intent.succeeded":
        var paymentIntent stripe.PaymentIntent
        err := json.Unmarshal(event.Data.Raw, &paymentIntent)
        if err != nil {
            fmt.Fprintf(os.Stderr, "Error parsing webhook JSON: %v\n", err)
            w.WriteHeader(http.StatusBadRequest)
            return
        }
        fmt.Println("PaymentIntent was successful!")
    case "payment_method.attached":
        var paymentMethod stripe.PaymentMethod
        err := json.Unmarshal(event.Data.Raw, &paymentMethod)
        if err != nil {
            fmt.Fprintf(os.Stderr, "Error parsing webhook JSON: %v\n", err)
            w.WriteHeader(http.StatusBadRequest)
            return
        }
        fmt.Println("PaymentMethod was attached to a Customer!")
    // ... handle other event types
    default:
        fmt.Fprintf(os.Stderr, "Unhandled event type: %s\n", event.Type)
    }

    w.WriteHeader(http.StatusOK)
})

.NET


// Set your secret key. Remember to switch to your live secret key in production.
// See your keys here: https://dashboard.stripe.com/apikeys
StripeConfiguration.ApiKey = "<<YOUR_SECRET_KEY>>";

using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Stripe;

namespace workspace.Controllers
{
    [Route("api/[controller]")]
    public class StripeWebHook : Controller
    {
        // If you are testing your webhook locally with the Stripe CLI you
        // can find the endpoint's secret by running `stripe listen`
        // Otherwise, find your endpoint's secret in your webhook settings
        // in the Developer Dashboardconst string endpointSecret = "whsec_...";

        [HttpPost]
        public async Task<IActionResult> Index()
        {
            var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();

            try
            {var stripeEvent = EventUtility.ConstructEvent(json,
                    Request.Headers["Stripe-Signature"], endpointSecret);

                // Handle the event
                // If on SDK version < 46, use class Events instead of EventTypes
                if (stripeEvent.Type == EventTypes.PaymentIntentSucceeded)
                {
                    var paymentIntent = stripeEvent.Data.Object as PaymentIntent;
                    Console.WriteLine("PaymentIntent was successful!");
                }
                else if (stripeEvent.Type == EventTypes.PaymentMethodAttached)
                {
                    var paymentMethod = stripeEvent.Data.Object as PaymentMethod;
                    Console.WriteLine("PaymentMethod was attached to a Customer!");
                }
                // ... handle other event types
                else
                {
                    Console.WriteLine("Unhandled event type: {0}", stripeEvent.Type);
                }

                return Ok();
            }catch (StripeException e)
            {
              return BadRequest(e.Message);
            }
        }
    }
}

Verify manually

Verify webhook signatures manually

Although we recommend that you use our official libraries to verify webhook event signatures, you can create a custom solution by following this section.

The Stripe-Signature header included in each signed event contains a timestamp and one or more signatures that you must verify. The timestamp has a t= prefix, and each signature has a scheme prefix. Schemes start with v, followed by an integer. Currently, the only valid live signature scheme is v1. To aid with testing, Stripe sends an additional signature with a fake v0 scheme, for test events.

Stripe-Signature:
t=1492774577,
v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd,
v0=6ffbb59b2300aae63f272406069a9788598b792a944a07aba816edb039989a39

We provide newlines for clarity, but a real Stripe-Signature header is on a single line.

Stripe generates signatures using a hash-based message authentication code (HMAC) with SHA-256. To prevent downgrade attacks, ignore all schemes that arent v1.

You can have multiple signatures with the same scheme-secret pair when you roll an endpoints secret, and keep the previous secret active for up to 24 hours. During this time, your endpoint has multiple active secrets and Stripe generates one signature for each secret.

To create a manual solution for verifying signatures, you must complete the following steps:

Step 1: Extract the timestamp and signatures from the header

Split the header using the , character as the separator to get a list of elements. Then split each element using the = character as the separator to get a prefix and value pair.

The value for the prefix t corresponds to the timestamp, and v1 corresponds to the signature (or signatures). You can discard all other elements.

Step 2: Prepare the signed_payload string

The signed_payload string is created by concatenating:

  • The timestamp (as a string)
  • The character .
  • The actual JSON payload (that is, the request body)

Step 3: Determine the expected signature

Compute an HMAC with the SHA256 hash function. Use the endpoints signing secret as the key, and use the signed_payload string as the message.

Step 4: Compare the signatures

Compare the signature (or signatures) in the header to the expected signature. For an equality match, compute the difference between the current timestamp and the received timestamp, then decide if the difference is within your tolerance.

To protect against timing attacks, use a constant-time-string comparison to compare the expected signature to each of the received signatures.

Debug webhook integrations

Multiple types of issues can occur when delivering events to your webhook endpoint:

  • Stripe might not be able to deliver an event to your webhook endpoint.
  • Your webhook endpoint might have an SSL issue.
  • Your network connectivity is intermittent.
  • Your webhook endpoint isnt receiving events that you expect to receive.

View event deliveries

You can also use the Stripe CLI to listen for events directly in your terminal.

To view event deliveries, select the webhook endpoint under Webhooks, then select the Events tab.

The Events tab provides a list of events and whether theyre Delivered, Pending, or Failed. Click an event to view metadata, including the HTTP status code of the delivery attempt and the time of pending future deliveries.

Fix HTTP status codes

When an event displays a status code of 200, it indicates successful delivery to the webhook endpoint. You might also receive a status code other than 200. View the table below for a list of common HTTP status codes and recommended solutions.

Pending webhook status Description Fix
(Unable to connect) ERR Were unable to establish a connection to the destination server. Make sure that your host domain is publicly accessible to the internet.
(302) ERR (or other 3xx status) The destination server attempted to redirect the request to another location. We consider redirect responses to webhook requests as failures. Set the webhook endpoint destination to the URL resolved by the redirect.
(400) ERR (or other 4xx status) The destination server cant or wont process the request. This might occur when the server detects an error (400), when the destination URL has access restrictions, (401, 403), or when the destination URL doesnt exist (404). - Make sure that your endpoint is publicly accessible to the internet.
  • Make sure that your endpoint accepts a POST HTTP method. | | (500) ERR (or other 5xx status) | The destination server encountered an error while processing the request. | Review your applications logs to understand why its returning a 500 error. | | (TLS error) ERR | We couldnt establish a secure connection to the destination server. Issues with the SSL/TLS certificate or an intermediate certificate in the destination servers certificate chain usually cause these errors. Stripe requires TLS (TLS refers to the process of securely transmitting data between the client—the app or browser that your customer is using—and your server. This was originally performed using the SSL (Secure Sockets Layer) protocol) version v1.2 or higher. | Perform an SSL server test to find issues that might cause this error. | | (Timed out) ERR | The destination server took too long to respond to the webhook request. | Make sure you defer complex logic and return a successful response immediately in your webhook handling code. |

Event delivery behaviors

This section helps you understand different behaviors to expect regarding how Stripe sends events to your webhook endpoint.

Automatic retries

Stripe attempts to deliver events to your destination for up to three days with an exponential back off in live mode. View when the next retry will occur, if applicable, in your event destinations Event deliveries tab. We retry event deliveries created in a sandbox three times over the course of a few hours. If your destination has been disabled or deleted when we attempt a retry, we prevent future retries of that event. However, if you disable and then re-enable the event destination before were able to retry, you still see future retry attempts.

Manual retries

There are two ways to manually retry events:

  • In the Stripe Dashboard, click Resend when looking at a specific event. This works for up to 15 days after the event creation.
  • With the Stripe CLI, run the stripe events resend <event_id> --webhook-endpoint=<endpoint_id> command. This works for up to 30 days after the event creation.

Manually resending an event that had previous delivery failures to a webhook endpoint doesnt dismiss Stripes automatic retry behavior. Automatic retries still happen until you respond to one of them with a 2xx status code.

Event ordering

Stripe doesnt guarantee the delivery of events in the order that theyre generated. For example, creating a subscription might generate the following events:

  • customer.subscription.created
  • invoice.created
  • invoice.paid
  • charge.created (if theres a charge)

Make sure that your event destination isnt dependent on receiving events in a specific order. Be prepared to manage their delivery appropriately. You can also use the API to retrieve any missing objects. For example, you can retrieve the invoice, charge, and subscription objects with the information from invoice.paid if you receive this event first.

API versioning

The API version in your account settings when the event occurs dictates the API version, and therefore the structure of an Event sent to your destination. For example, if your account is set to an older API version, such as 2015-02-16, and you change the API version for a specific request with versioning, the Event object generated and sent to your destination is still based on the 2015-02-16 API version. You cant change Event objects after creation. For example, if you update a charge, the original charge event remains unchanged. As a result, subsequent updates to your accounts API version dont retroactively alter existing Event objects. Retrieving an older Event by calling /v1/events using a newer API version also has no impact on the structure of the received event. You can set test event destinations to either your default API version or the latest API version. The Event sent to the destination is structured for the event destinations specified version.

Best practices for using webhooks

Review these best practices to make sure your webhook endpoints remain secure and function well with your integration.

Handle duplicate events

Webhook endpoints might occasionally receive the same event more than once. You can guard against duplicated event receipts by logging the event IDs youve processed, and then not processing already-logged events.

In some cases, two separate Event objects are generated and sent. To identify these duplicates, use the ID of the object in data.object along with the event.type.

Only listen to event types your integration requires

Configure your webhook endpoints to receive only the types of events required by your integration. Listening for extra events (or all events) puts undue strain on your server and we dont recommend it.

You can change the events that a webhook endpoint receives in the Dashboard or with the API.

Handle events asynchronously

Configure your handler to process incoming events with an asynchronous queue. You might encounter scalability issues if you choose to process events synchronously. Any large spike in webhook deliveries (for example, during the beginning of the month when all subscriptions renew) might overwhelm your endpoint hosts.

Asynchronous queues allow you to process the concurrent events at a rate your system can support.

Exempt webhook route from CSRF protection

If youre using Rails, Django, or another web framework, your site might automatically check that every POST request contains a CSRF token. This is an important security feature that helps protect you and your users from cross-site request forgery attempts. However, this security measure might also prevent your site from processing legitimate events. If so, you might need to exempt the webhooks route from CSRF protection.

Rails

class StripeController < ApplicationController
  # If your controller accepts requests other than Stripe webhooks,
  # you'll probably want to use `protect_from_forgery` to add CSRF
  # protection for your application. But don't forget to exempt
  # your webhook route!
  protect_from_forgery except: :webhook

  def webhook
    # Process webhook data in `params`
  end
end

Django

import json

# Webhooks are always sent as HTTP POST requests, so ensure
# that only POST requests reach your webhook view by
# decorating `webhook()` with `require_POST`.
#
# To ensure that the webhook view can receive webhooks,
# also decorate `webhook()` with `csrf_exempt`.
@require_POST
@csrf_exempt
def webhook(request):
  # Process webhook data in `request.body`

Receive events with an HTTPS server

If you use an HTTPS URL for your webhook endpoint (required in live mode), Stripe validates that the connection to your server is secure before sending your webhook data. For this to work, your server must be correctly configured to support HTTPS with a valid server certificate. Stripe webhooks support only TLS (TLS refers to the process of securely transmitting data between the client—the app or browser that your customer is using—and your server. This was originally performed using the SSL (Secure Sockets Layer) protocol) versions v1.2 and v1.3.

Roll endpoint signing secrets periodically

The secret used for verifying that events come from Stripe is modifiable in the Webhooks tab in Workbench. To keep them safe, we recommend that you roll (change) secrets periodically, or when you suspect a compromised secret.

To roll a secret:

  1. Click each endpoint in the Workbench Webhooks tab that you want to roll the secret for.
  2. Navigate to the overflow menu (⋯) and click Roll secret. You can choose to immediately expire the current secret or delay its expiration for up to 24 hours to allow yourself time to update the verification code on your server. During this time, multiple secrets are active for the endpoint. Stripe generates one signature per secret until expiration.

Verify events are sent from Stripe

Stripe sends webhook events from a set list of IP addresses. Only trust events coming from these IP addresses.

Also verify webhook signatures to confirm that Stripe sent the received events. Stripe signs webhook events it sends to your endpoints by including a signature in each events Stripe-Signature header. This allows you to verify that the events were sent by Stripe, not by a third party. You can verify signatures either using our official libraries, or verify manually using your own solution.

The following section describes how to verify webhook signatures:

  1. Retrieve your endpoints secret.
  2. Verify the signature.

Retrieving your endpoints secret

Use Workbench and go to the Webhooks tab to view all your endpoints. Select an endpoint that you want to obtain the secret for, then click Click to reveal.

Stripe generates a unique secret key for each endpoint. If you use the same endpoint for both test and live API keys, the secret is different for each one. Additionally, if you use multiple endpoints, you must obtain a secret for each one you want to verify signatures on. After this setup, Stripe starts to sign each webhook it sends to the endpoint.

Preventing replay attacks

A replay attack is when an attacker intercepts a valid payload and its signature, then re-transmits them. To mitigate such attacks, Stripe includes a timestamp in the Stripe-Signature header. Because this timestamp is part of the signed payload, its also verified by the signature, so an attacker cant change the timestamp without invalidating the signature. If the signature is valid but the timestamp is too old, you can have your application reject the payload.

Our libraries have a default tolerance of 5 minutes between the timestamp and the current time. You can change this tolerance by providing an additional parameter when verifying signatures. Use Network Time Protocol (NTP) to make sure that your servers clock is accurate and synchronizes with the time on Stripes servers.

Dont use a tolerance value of 0. Using a tolerance value of 0 disables the recency check entirely.

Stripe generates the timestamp and signature each time we send an event to your endpoint. If Stripe retries an event (for example, your endpoint previously replied with a non-2xx status code), then we generate a new signature and timestamp for the new delivery attempt.

Quickly return a 2xx response

Your endpoint must quickly return a successful status code (2xx) prior to any complex logic that could cause a timeout. For example, you must return a 200 response before updating a customers invoice as paid in your accounting system.

See also

Handle webhook versioning

Learn how to upgrade the API version of your webhook endpoint.

Thin events for API v1 resources are available in private preview. You can use them to streamline integration upgrades without changing your webhook configuration. Previously, thin events only supported API v2 resources. Learn more and request access.

Webhook endpoints either have a specific API version set or use the default API version of the Stripe account. If you use any of our static language SDKs (.NET, Java or Go) to process events, the API version set for webhooks should match the version used to generate the SDKs. Matching these versions ensures successful deserialization of the event object.

Use this guide to safely upgrade your webhook endpoints to a newer API version that may have breaking changes.

Figure out if the new API version has breaking changes

Every API version prior to 2024-09-30.acacia has breaking changes.

Starting with the 2024-09-30.acacia release, Stripe follows a new API release process where we release new API versions monthly with no breaking changes. Twice a year, we issue a new release (for example, Acacia) that starts with an API version that has breaking changes. You can safely upgrade your webhook endpoints to any API version in the same release without making changes to your integration.

Create a new disabled webhook endpoint

Create a new webhook endpoint with the following parameters:

  • url: the same URL as your original webhook endpoint, but add a query parameter to distinguish between events sent to the two different endpoints. For example https://example.com/webhooks?version=2024-04-10.
  • enabled_events: the same events as your original webhook endpoint.
  • api_version: the API version you want to upgrade to. If youre upgrading to the latest API version, you can use the Dashboard or the API to create the endpoint. For other versions, use the API to set a specific version.

After you create the new webhook endpoint, disable it. You will re-enable it in the next step. Two endpoints, but only the old one is sending events

Update your webhook code to ignore events sent to the new endpoint

Update your event processing code:

  • If the query parameter is for the older API version, process it as usual.
  • If the query parameter is for the newer API version, ignore the event and return a 200 response to prevent delivery retries.

Next, enable the new webhook endpoint that you created in the previous step. At this point every event is sent twice: once with the old API version and once with the new one. Two endpoints sending events, but only processing the old one

Update your webhook code to process events for the new endpoint

Update the version of the Stripe library youre using to match the version of your new webhook endpoint. Make sure to read the changelog and handle any breaking changes.

Update your event processing code:

  • If the query parameter is for the older version, ignore the event. We recommend returning a 400 status to let Stripe automatically retry the event. This ensures that if you need to revert, events are re-sent to the older webhook endpoint.
  • If the query parameter is for the new version, process it. Two endpoints sending events, but only processing the new one

Monitor your new webhook endpoint

If events arent being correctly handled by your new code, try the following:

  1. Revert to the earlier version of your code.
  2. Temporarily disable the new webhook endpoint.
  3. Process the failed events (if you returned a 400 status as described in the previous step, Stripe automatically resends all the events).
  4. Investigate and fix the issue.
  5. Enable the new webhook endpoint and resume monitoring.

Disable the old webhook endpoint

Once the upgrade is successful, disable the old webhook endpoint to stop your server from returning 400 status. If you dont disable it, this may cause issues with integrations that relies on a 200 response.

After you disable the old webhook endpoint, Stripe wont re-deliver events that returned a 400. Two endpoints, but only the new one is sending events

Resolve webhook signature verification errors

Learn how to fix a common error when listening to webhook events.

When processing webhook events, we recommend securing your endpoint by verifying that the event is coming from Stripe. To do this, use the Stripe-Signature header and call the constructEvent() function with three parameters:

  • requestBody: The request body string sent by Stripe.
  • signature: The Stripe-Signature header in the request sent by Stripe.
  • endpointSecret: The secret associated with your endpoint.

This function might look like this:

Ruby

Stripe::Webhook.construct_event(request_body, signature, endpoint_secret)

Python

stripe.Webhook.construct_event(request_body, signature, endpoint_secret)

PHP

\Stripe\Webhook::constructEvent($request_body, $signature, $endpoint_secret);

Java

Webhook.constructEvent(requestBody, signature, endpointSecret);

Node.js

stripe.webhooks.constructEvent(requestBody, signature, endpointSecret);

Go

webhook.ConstructEvent(requestBody, signature, endpointSecret)

.NET

EventUtility.ConstructEvent(requestBody, signature, endpointSecret);

If you get the following Webhook signature verification failed error, at least one of the three parameters you passed to the constructEvent() function is incorrect.

Webhook signature verification failed. Err: No signatures found matching the expected signature for payload.

Check the endpoint secret

The most common error is using the wrong endpoint secret. If youre using a webhook endpoint created in the Dashboard, open the endpoint in the Dashboard and click the Reveal secret link near the top of the page to view the secret. If youre using the Stripe CLI, the secret is printed in the Terminal when you run the stripe listen command.

In both cases, the secret starts with a whsec_ prefix, but the secret itself is different. Dont verify signatures on events forwarded by the CLI using the secret from a Dashboard-managed endpoint, or the other way around. Finally, print the endpointSecret used in your code, and make sure that it matches the one you found above.

Check the request body

The request body must be the body string that Stripe sends in UTF-8 encoding without any changes. When you print it as a string, it looks similar to this:

{
  "id": "evt_xxx",
  "object": "event",
  "data": {
      ...
  }
}

Retrieve the raw request body

Some frameworks might edit the request body by doing things like adding or removing whitespace, reordering the key-value pairs, converting the string to JSON, or changing the encoding. All of these cases lead to a failed signature verification.

The following is a non-exhaustive list of frameworks that might parse or mutate the data using common configurations, and some tips on how to get the raw request body.

Framework Retrieval method
stripe-node library with Express Follow our integration quickstart guide.
stripe-node library with Body Parser Try solutions listed in this GitHub issue.
stripe-node library with Next.js App Router Take a look at this working example.
stripe-node library with Next.js Pages Router Try disabling bodyParser and using buffer(request), like in this example.

If youre using the stripe-node library with Express, make sure that app.use(express.json()) is placed after the webhook route. In Express, the order of middleware configuration matters. If express.json() is applied before your webhook route, it parses the request body before signature verification, causing the verification to fail. For example:

// Webhook route in its original request form
app.post('/webhook', ...);

// Parse the request body in JSON for other routes
app.use(express.json());

// Put other routes here
app.post('/another-route', ...);

AWS API Gateway with Lambda function

To retrieve the raw request body for the AWS API Gateway with Lambda function, in the API Gateway, set up a Body Mapping Template of content type application/json:

{
  "method": "$context.httpMethod",
  "body": $input.json('$'),
  "rawBody": "$util.escapeJavaScript($input.body).replaceAll("\\'", "'")",
  "headers": {
    #foreach($param in $input.params().header.keySet())
    "$param": "$util.escapeJavaScript($input.params().header.get($param))"
    #if($foreach.hasNext),#end
    #end
  }
}

Then, in the Lambda function, access the raw body with the events rawBody property and the headers with the events headers property.

Check the signature

Print the signature parameter, and confirm that it looks similar to this:

t=xxx,v1=yyy,v0=zzz

If not, check if you have an issue in your code when trying to extract the signature from the header.

Process undelivered webhook events

Learn how to manually process undelivered webhook events.

If your webhook endpoint temporarily cant process events, Stripe automatically resends the undelivered events to your endpoint for up to three days, increasing the time for your webhook endpoint to eventually receive and process all events.

This guide explains how to speed up that process by manually processing the undelivered events.

List webhook events

Call the List Events API with the following parameters:

  • ending_before: Specify an event ID that was sent just before the webhook endpoint became unavailable.
  • types: Specify the list of event types to retrieve.
  • delivery_success: Set to false to retrieve events that were unsuccessfully delivered to at least one of your webhook endpoints.

Stripe only returns events created in the last 30 days.

curl -G https://api.stripe.com/v1/events \
  -u "<<YOUR_SECRET_KEY>>:" \
  -d ending_before=evt_001 \
  -d "types[]"="payment_intent.succeeded" \
  -d "types[]"="payment_intent.payment_failed" \
  -d delivery_success=false
stripe events list  \
  --ending-before=evt_001 \
  -d "types[0]"="payment_intent.succeeded" \
  -d "types[1]"="payment_intent.payment_failed" \
  --delivery-success=false
# Set your secret key. Remember to switch to your live secret key in production.
# See your keys here: https://dashboard.stripe.com/apikeys
client = Stripe::StripeClient.new("<<YOUR_SECRET_KEY>>")

events = client.v1.events.list({
  ending_before: 'evt_001',
  types: ['payment_intent.succeeded', 'payment_intent.payment_failed'],
  delivery_success: false,
})
# Set your secret key. Remember to switch to your live secret key in production.
# See your keys here: https://dashboard.stripe.com/apikeys
client = StripeClient("<<YOUR_SECRET_KEY>>")

# For SDK versions 12.4.0 or lower, remove '.v1' from the following line.
events = client.v1.events.list({
  "ending_before": "evt_001",
  "types": ["payment_intent.succeeded", "payment_intent.payment_failed"],
  "delivery_success": False,
})
// Set your secret key. Remember to switch to your live secret key in production.
// See your keys here: https://dashboard.stripe.com/apikeys
$stripe = new \Stripe\StripeClient('<<YOUR_SECRET_KEY>>');

$events = $stripe->events->all([
  'ending_before' => 'evt_001',
  'types' => ['payment_intent.succeeded', 'payment_intent.payment_failed'],
  'delivery_success' => false,
]);
// Set your secret key. Remember to switch to your live secret key in production.
// See your keys here: https://dashboard.stripe.com/apikeys
StripeClient client = new StripeClient("<<YOUR_SECRET_KEY>>");

EventListParams params =
  EventListParams.builder()
    .setEndingBefore("evt_001")
    .addType("payment_intent.succeeded")
    .addType("payment_intent.payment_failed")
    .setDeliverySuccess(false)
    .build();

// For SDK versions 29.4.0 or lower, remove '.v1()' from the following line.
StripeCollection<Event> stripeCollection = client.v1().events().list(params);
// Set your secret key. Remember to switch to your live secret key in production.
// See your keys here: https://dashboard.stripe.com/apikeys
const stripe = require('stripe')('<<YOUR_SECRET_KEY>>');

const events = await stripe.events.list({
  ending_before: 'evt_001',
  types: ['payment_intent.succeeded', 'payment_intent.payment_failed'],
  delivery_success: false,
});
// Set your secret key. Remember to switch to your live secret key in production.
// See your keys here: https://dashboard.stripe.com/apikeys
sc := stripe.NewClient("<<YOUR_SECRET_KEY>>")
params := &stripe.EventListParams{
  Types: []*string{
    stripe.String("payment_intent.succeeded"),
    stripe.String("payment_intent.payment_failed"),
  },
  DeliverySuccess: stripe.Bool(false),
}
params.EndingBefore = stripe.String("evt_001")
result := sc.V1Events.List(context.TODO(), params)
// Set your secret key. Remember to switch to your live secret key in production.
// See your keys here: https://dashboard.stripe.com/apikeys
var options = new EventListOptions
{
    EndingBefore = "evt_001",
    Types = new List<string>
    {
        "payment_intent.succeeded",
        "payment_intent.payment_failed",
    },
    DeliverySuccess = false,
};
var client = new StripeClient("<<YOUR_SECRET_KEY>>");
var service = client.V1.Events;
StripeList<Event> events = service.List(options);

By default, the response returns up to 10 events. To retrieve all events, use auto-pagination after retrieving the results.

Ruby

events = Stripe::Event.list({
  ending_before: 'evt_001',
  types: ['payment_intent.succeeded', 'payment_intent.payment_failed'],
  delivery_success: false,
})

events.auto_paging_each do |event|
  # This function is defined in the next section
  process_event(event)
end

Python

events = stripe.Event.list(
  ending_before="evt_001",
  types=["payment_intent.succeeded", "payment_intent.payment_failed"],
  delivery_success=False,
)

for event in events.auto_paging_iter():
  # This function is defined in the next section
  process_event(event)

PHP

$events = $stripe->events->all([
  'ending_before' => 'evt_001',
  'types' => ['payment_intent.suceeded', 'payment_intent.payment_failed'],
  'delivery_success' => false,
]);

foreach ($events->autoPagingIterator() as $event) {
  // This function is defined in the next section
  process_event($event);
}

Node.js

stripe.events.list({
  ending_before: 'evt_001',
  types: ['payment_intent.succeeded', 'payment_intent.payment_failed'],
  delivery_success: false,
}).autoPagingEach(event => {
  // This function is defined in the next section
  process_event(event);
});

Using ending_before with auto-pagination returns events in chronological order. This lets you process events in their created order.

Process the events

Process only unsuccessfully processed events according to your own logic to avoid processing a single event multiple times by, for example:

  • Inadvertently running the script twice in a row
  • Simultaneously running the script while Stripe automatically resends some of the unprocessed events

Ruby

def process_event(event)
  if is_processing_or_processed(event)
    puts "skipping event #{event.id}"
  else
    puts "processing event #{event.id}"
    mark_as_processing(event)

    # Process the event
    # ...

    mark_as_processed(event)
  end
end

Python

def process_event(event):
    if is_processing_or_processed(event):
        print(f"skipping event {event.id}")
    else:
        print(f"processing event {event.id}")
        mark_as_processing(event)

        # Process the event
        # ...

        mark_as_processed(event)

PHP

function process_event($event) {
    if (is_processing_or_processed($event)) {
        echo "skipping event " . $event->id;
    } else {
        echo "processing event " . $event->id;
        mark_as_processing($event);

        // Process the event
        // ...

        mark_as_processed($event);
    }
}

Node.js

function process_event(event) {
  if (is_processing_or_processed(event)) {
    console.log(`skipping event ${event.id}`);
  } else {
    console.log(`processing event ${event.id}`);
    mark_as_processing(event);

    // Process the event
    // ...

    mark_as_processed(event);
  }
}

Define the following functions that prevent processing duplication:

  • is_processing_or_processed to check the events status in your database.
  • mark_as_processing to update your database to mark the event as processing.
  • mark_as_processed to update your database to mark the event as processed.

Respond to automatic retries

Stripe still considers your manually preocessed events as undelivered, so continues to automaticly retry them.

When your webhook endpoint receives an already processed event, ignore the event and return a successful response to stop future retries.

Ruby

require 'json'

# Using Sinatra
post '/webhook' do
  payload = request.body.read
  event = nil

  begin
    event = Stripe::Event.construct_from(
      JSON.parse(payload, symbolize_names: true)
    )
  rescue JSON::ParserError => e
    # Invalid payload
    status 400
    return
  end

  if is_processing_or_processed(event)
    puts "skipping event #{event.id}"
  else
    puts "processing event #{event.id}"
    mark_as_processing(event)

    # Process the event
    # ...

    mark_as_processed(event)
  end

  status 200
end

Python

import json
from django.http import HttpResponse

# Using Django
@csrf_exempt
def my_webhook_view(request):
  payload = request.body
  event = None

  try:
    event = stripe.Event.construct_from(
      json.loads(payload), stripe.api_key
    )
  except ValueError as e:
    # Invalid payload
    return HttpResponse(status=400)

  if is_processing_or_processed(event):
    print(f"skipping event {event.id}")
  else:
    print(f"processing event {event.id}")
    mark_as_processing(event)

    # Process the event
    # ...

    mark_as_processed(event)

  return HttpResponse(status=200)

PHP

$payload = @file_get_contents('php://input');
$event = null;

try {
    $event = \Stripe\Event::constructFrom(
        json_decode($payload, true)
    );
} catch(\UnexpectedValueException $e) {
    // Invalid payload
    http_response_code(400);
    exit();
}

if (is_processing_or_processed($event)) {
    echo "skipping event {$event->id}";
} else {
    echo "processing event {$event->id}";
    mark_as_processing($event);

    // Process the event
    // ...

    mark_as_processed($event);
}

http_response_code(200);
exit();

Node.js

// This example uses Express to receive webhooks
const express = require('express');
const app = express();

app.post('/webhook', express.json({type: 'application/json'}), (request, response) => {
  const event = request.body;

  if (is_processing_or_processed(event.id)) {
    console.log(`skipping event ${event.id}`);
  } else {
    console.log(`processing event ${event.id}`);
    mark_as_processing(event);

    // Process the event
    // ...

    mark_as_processed(event);
  }

  response.json({received: true});
});