Handling webhooks for Stripe Connect

Did you know that you can build automations that trigger when events happen on your Stripe account? These automations are typically implemented by listening for webhook event notifications (POST requests) sent to a special route on your server called a webhook handler. While not strictly required, it’s a best practice to use webhooks for many parts of a Stripe Connect integration, including onboarding.

Background

We’ll use the term “platform account” or “platform” to talk about the Stripe Account that you will use to build your application. A “connected account” is another Stripe Account for your user that you will be facilitating payments on behalf of. We’ll also talk about “end-customers,” the customers buying from your users. To learn more about picking the right connected account types, or charge flows (direct vs. destination vs. separate charge and transfer), see the previous articles in this series.

Differences between direct account webhooks and Connect webhooks

To set up a webhook handler, you'll add a route to your server, then create a webhook endpoint in Stripe that points at your public endpoint. There are two types of webhook endpoints that you can configure:

  1. Direct or Account webhook endpoints (events from your platform account)
  2. Connect webhook endpoints (events from accounts connected to your platform)

There are some nuanced differences between the two:

Webhook event notifications for Connect events include the related account’s ID in the payload, but payloads for Direct endpoints do not include your own platform account ID. Here’s an example of a Connect event, which you can tell by the account field being present:

{
  "id": "evt_mCtVEgOX48Guwy",
  "livemode": true,
  "object": "event",
  "type": "customer.created",
  "account": "acct_HzrXqpz4WWwqUx",
  "pending_webhooks": 2,
  "created": 1349654313,
  "data": {...}
}

How to create a Connect webhook handler

You’ll likely need to set up two separate webhook endpoints in Stripe for your integration. You can either create two separate routes on your server (e.g., /webhooks and /connect-webhooks) or use the same route that handles event notifications for both webhook event types. You only create one Connect endpoint for all connected accounts on your platform – no need to create a new endpoint for each connected account. In this article, we’ll focus on the Connect details. If you’re new to webhooks check out the videos and find an example webhook endpoint implementation in the dashboard when creating a new webhook endpoint.

Note that each webhook endpoint you create has its own unique webhook signing secret to verify the POST request came from Stripe’s servers. We’ll see later that using the Stripe CLI for development will give you a special shared webhook signing secret that works for two endpoints.

Let’s look at a couple of examples:

  • When a new Payout is created, a payout.created event fires, and the payload includes an arrival_date. We can build an automation to email the connected account holders to let them know when to expect their payout to be deposited. We’ll handle this event in our Connect webhook handler.
  • When a new Payment is collected from an end-customer, a payment_intent.succeeded event fires, letting us know we need to fulfill the order. Assuming we’re using destination charges, this fires on your platform account.

Option 1 - Implement with two separate routes

This approach is simple and gives you good separation between your Connect webhook event handling logic and your account webhook event handling logic.

Both live and testmode events are sent to your production Connect webhook endpoints. This is so you can build test workflows for connected accounts that are connected through live mode. This means you should always check the livemode property of the event notification to ensure the event is in the correct mode.

app.post('/connect-webhook', bodyParser.raw({type: 'application/json'}), (request, response) => {
  const sig = request.headers['stripe-signature'];  
  const endpointSecret = 'whsec_YOUR_CONNECT_ENDPOINT_SECRET';

  let event;

  // Verify webhook signature and extract the event.
  // See https://stripe.com/docs/webhooks/signatures for more information.
  try {
    event = stripe.webhooks.constructEvent(request.body, sig, endpointSecret);
  } catch (err) {
    return response.status(400).send(`Webhook Error: ${err.message}`);
  }

  // We only want to run these automations for livemode…
  if (event.livemode) {
    if (event.type === 'payout.created') {
      const payout = event.data.object;
      const connectedAccountId = event.account;
      sendPayoutComingSoonEmailToConnectedAccountUser(connectedAccountId, payout);
    }
  }

  response.json({received: true});
});
app.post('/webhook', bodyParser.raw({type: 'application/json'}), (request, response) => {
  const sig = request.headers['stripe-signature'];  
  const endpointSecret = 'whsec_YOUR_DIRECT_WEBHOOK_SECRET';

  let event;

  // Verify webhook signature and extract the event.
  // See https://stripe.com/docs/webhooks/signatures for more information.
  try {
    event = stripe.webhooks.constructEvent(request.body, sig, endpointSecret);
  } catch (err) {
    return response.status(400).send(`Webhook Error: ${err.message}`);
  }

  if (event.type === 'payment_intent.succeeded') {
    const paymentIntent = event.data.object;
    // event.account is undefined since this is an event from a direct webhook endpoint.
    fulfillOrder(paymentIntent);
  }

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

Option 2 - Reuse the same route for both webhook event types

It can be convenient to reuse the same webhook endpoint for all Stripe use-cases. You might even want to reuse the same webhook endpoint across multiple third-party integrations. I want to share one trick for verifying webhook signing secrets when you have multiple: nesting verification for the second signature in the catch block of the first.

app.post('/webhooks', bodyParser.raw({type: 'application/json'}), (request, response) => {
  const sig = request.headers['stripe-signature'];  
  const connectEndpointSecret = 'whsec_YOUR_CONNECT_ENDPOINT_SECRET';
  const directEndpointSecret = 'whsec_YOUR_DIRECT_WEBHOOK_SECRET'
  let event;

  // Verify webhook signature and extract the event.
  // See https://stripe.com/docs/webhooks/signatures for more information.
  try {
    event = stripe.webhooks.constructEvent(request.body, sig, connectEndpointSecret);
  } catch (err) {
    if (err.type == 'StripeSignatureVerificationError'){
      try {
        event = stripe.webhooks.constructEvent(request.body, sig, directEndpointSecret);
      } catch (err2) {
        return response.status(400).send(`Webhook Error: ${err2.message}`);
      }
    } else { 
      console.error(`Webhook error: ${err.message}`);
    }
  }

  // We only want to run these automations for livemode...
  if (event.livemode) {
    if (event.type === 'payout.created') {
      const payout = event.data.object;
      const connectedAccountId = event.account;
      sendPayoutComingSoonEmailToConnectedAccountUser(connectedAccountId, payout);
    }
  }

  if (event.type === 'payment_intent.succeeded') {
    const paymentIntent = event.data.object;
    // event.account is undefined since this is an event from a direct webhook endpoint.
    fulfillOrder(paymentIntent);
  }

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

How to build and test webhooks locally with the Stripe CLI

For Stripe to send a POST request to your server, you need a publicly accessible URL. There are a couple of alternatives when working on building and testing webhooks locally. You could use a tunneling tool like ngrok, but we recommend using the Stripe CLI, a purpose-built tool for helping with all aspects of building your integration.

The Stripe CLI comes with the listen command that creates a direct connection between your local running server and Stripe. When events fire on your Stripe account (or connected accounts) those are forwarded to your local running webserver.

To get started with the Stripe CLI, first install it, then run stripe login to authenticate with your Stripe Account.

Then, run the listen command:

stripe listen --forward-to localhost:4242/webhook --forward-connect-to localhost:4242/connect-webhook -–latest
  • forward-to specifies the URL for the direct webhook handler
  • forward-connect-to specifies the URL for the connect webhook handler

If you are re-using the same route for both direct and connect webhook events, then you would run:

stripe listen --forward-to localhost:4242/webhook --forward-connect-to localhost:4242/webhook -–latest
  • latest tells the CLI to create a webhook endpoint that generates events based on the most recent API version. This is important when using strongly typed SDKs like stripe-java and stripe-dotnet which are pinned to specific API versions. By default, a webhook endpoint will be created with your Stripe Account’s default API version. If that is different from the API version your SDK is pinned to, then the SDK will not be able to properly deserialize the event payload.

That starts a listener and prints out the webhook signing secret:

01-stripe-cli-listen-output.png

In my case, the signing secret is whsec_9d75cc10168ca3f518f64a69a5015bc07222290a199b27985efe350c7c59ecde.

To see events fire, we can either take actions in our application or the Stripe dashboard that result in the event types we want to test. A simpler and more streamlined approach to testing is to use the Stripe CLI’s built-in fixtures via the trigger command. trigger runs one or many API calls to Stripe to result in a given event firing and subsequently being delivered to your webhook handlers.

We can trigger the payment_intent.succeeded event on our platform account like so:

stripe trigger payment_intent.succeeded

That creates and confirms a payment intent. Notice the logs under the stripe listen output show us the HTTP status code, request method and URL, and the ID of the events firing:

02-trigger-without-account.png

We can also trigger events on connected accounts by passing the --stripe-account option with the ID of the connected account. Here’s an example for triggering a payout.created event for a connected account with ID acct_1KPRf02R4eodYxfv:

stripe trigger payout.created --stripe-account acct_1KPRf02R4eodYxfv

03-trigger-with-account.png

You can also use the Stripe for VS Code extension, a thin UI wrapper around the Stripe CLI.

Recommendations

Store the event payload

One of the best practices when working with webhooks is to store the event payloads for later use. This is especially useful when working with Stripe Connect webhooks, as it allows you to re-process the events in case of errors or if you need to debug a specific event. I like to store event payloads in a database table with the raw payload in a JSON column and a few useful columns like processing status, processing errors, event ID, event source, and Stripe account ID.

By keeping a record of all events, you will have a historical record of all events that occurred in your platform, which gives you a full understanding of the events that have taken place, regardless of the outcome of the webhook event handler.

Only subscribe to events that you're using today

To improve your integration's performance, only subscribe to the webhook events you need, not all of them. This reduces the number of requests your webhook handler must handle. Also, if your server does not respond with 2XX status codes, Stripe will retry sending events for several days. This could result in a DDOS by the thundering herd of events received when your server comes back online.

You can easily subscribe to the events you need by selecting them in the webhooks settings in your Stripe Dashboard, or by using the Stripe API to configure your webhook endpoints. It's also important to keep in mind that webhook events can change over time, so it's a good idea to periodically review your webhook event subscriptions to ensure that you're still handling the events that are important to your integration.

You can even filter for specific event types when using the Stripe CLI listen command using the --events flag: \

stripe listen --forward-to localhost:4242/webhook --forward-connect-to localhost:4242/webhook --latest --events checkout.session.completed,account.updated

Respond immediately and then process the event with a background job

When building your webhook handler, it's important to respond immediately to the webhook event notification to avoid timing out and decrease latency. One approach is to immediately respond to the webhook event notification with a 200 status code, and then process the event in a background job. This allows your webhook handler to quickly acknowledge receipt of the event and free up resources to handle other incoming requests, while the background job can take the time needed to process the event.

Background jobs are especially useful when the event processing requires heavy computation, calls to external services, or database operations, which can take longer to complete. Additionally, it also allows you to handle events asynchronously, which improves the scalability and fault-tolerance of your webhook handler. There are several libraries available, like Bull, Celery, etc., which can help you set up background jobs in your application.

Here’s an example using bull with a Redis server to queue and process Stripe webhook event notifications in the background. We’ll start by creating a queue and giving it a function that will execute for each event, then when we receive a request to the webhook endpoint, we’ll enqueue a new event to be processed with Q.add():

const Queue = require('bull');
const redisHost = process.env.REDIS_HOST || '127.0.0.1';
const redisPort = process.env.REDIS_PORT || 6379;
const queueName = 'stripe_webhooks';

// A queue for the jobs scheduled based on a routine without any external requests
const Q = new Queue(queueName, { redis: { port: redisPort, host: redisHost } });

Q.process(function (event, done) {
  // We only want to run these automations for livemode...
  if (event.livemode) {
    if (event.type === 'payout.created') {
      const payout = event.data.object;
      const connectedAccountId = event.account;
      sendPayoutComingSoonEmailToConnectedAccountUser(connectedAccountId, payout);
    }
  }

  if (event.type === 'payment_intent.succeeded') {
    console.log('đź’° Payment captured!');
  } else if (eventType === 'payment_intent.payment_failed') {
    console.log('❌ Payment failed.');
  }

  done(null, { t2: jobData.value * 2, t3: jobData.value * 3 });
});

app.post('/webhooks', bodyParser.raw({type: 'application/json'}), async (request, response) => {
  const sig = request.headers['stripe-signature'];  
  const connectEndpointSecret = 'whsec_YOUR_CONNECT_ENDPOINT_SECRET';
  const directEndpointSecret = 'whsec_YOUR_DIRECT_WEBHOOK_SECRET'
  let event;

  // Verify webhook signature and extract the event.
  // See https://stripe.com/docs/webhooks/signatures for more information.
  try {
    event = stripe.webhooks.constructEvent(request.body, sig, connectEndpointSecret);
  } catch (err) {
    if (err.type == 'StripeSignatureVerificationError'){
      try {
        event = stripe.webhooks.constructEvent(request.body, sig, directEndpointSecret);
      } catch (err2) {
        return response.status(400).send(`Webhook Error: ${err2.message}`);
      }
    } else { 
      console.error(`Webhook error: ${err.message}`);
    }
  }

  await Q.add(event)

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

Conclusion

In this article, you learned how to build automations with Stripe Connect webhooks. You read how to test and develop locally using Stripe CLI's listen command and how to verify webhook signatures to secure your webhook endpoint. If you’re following along with the series, we’ll use our new Stripe Connect webhook handlers in a future edition about onboarding connected accounts, so stay tuned!