Skip to content

Conversation

@xavdid-stripe
Copy link
Member

@xavdid-stripe xavdid-stripe commented Nov 11, 2025

Why?

We've been designing a streamlined approach to handling incoming events that is easy to get right and hard to get wrong. This PR has the initial implementation of this new system.

NOTE: final names for classes / methods are still being gaveled, so don't worry about those for now. Weigh in on this doc if you have strong feelings there!

The only other pending item is to add a method to allow handling a webhook without verifying the signature. This is good for testing and for Event Bridge, which doesn't use the signature-based verification. Otherwise, this is ready for review.

What?

  • add EventHandler class
  • add event handler constructor on StripeClient
  • add tests
  • add method for changing the stripe context on a client

Example usage

[Route("api/[controller]")]
[ApiController]
public class EventNotificationWebhookHandler : ControllerBase
{
    private readonly StripeClient client;
    private readonly StripeEventRouter eventRouter;

    public EventNotificationWebhookHandler()
    {
        client = new StripeClient(Environment.GetEnvironmentVariable("STRIPE_API_KEY"));
        eventRouter = client.Router(Environment.GetEnvironmentVariable("WEBHOOK_SECRET") ?? string.Empty, HandleUnhandledError);

        // register handlers
        eventRouter.V1BillingMeterErrorReportTriggeredEventNotification += HandleBillingMeterErrorReportTriggeredEventNotification;
    }

    private void HandleBillingMeterErrorReportTriggeredEventNotification(object sender, Stripe.StripeEventNotificationEventArgs<Stripe.Events.V1BillingMeterErrorReportTriggeredEventNotification> e)
    {
        var meter = e.EventNotification.FetchRelatedObject();
        Console.WriteLine($"Meter {meter.DisplayName} had an error");
    }

    private void HandleUnhandledError(object sender, Stripe.StripeUnhandledEventNotificationEventArgs e)
    {
        Console.WriteLine($"Received unhandled event notification type: {e.EventNotification.Type}");
    }

    [HttpPost]
    public async Task<IActionResult> Index()
    {
        var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();
        eventRouter.Handle(json, Request.Headers["Stripe-Signature"]);
        return null;
    }
}

See Also

@xavdid-stripe xavdid-stripe changed the base branch from master to beta November 15, 2025 18:12
/// <param name="client">The StripeClient instance to use for parsing and API requests.</param>
/// <param name="webhookSecret">The webhook secret used for signature verification.</param>
/// <param name="unhandledEventHandler">TODO: ADD UNHANDLED DETAILS.</param>
public StripeEventRouter(StripeClient client, string webhookSecret, EventHandler<StripeUnhandledEventNotificationEventArgs> unhandledEventHandler)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since unhandledEventHandler i'm not convinced it actually needs to be an event handler? but i'm not sure if that's what users expect anyway

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dont think I've seen any classes take an event handler as a constructor arg, but also I don't think I've seen too many patterns where an event is expected to have a handler when it is fired or else its an error. I think the shape of this is fine, but if you wanted to make it a little more "this is just a function"-like, you could do:

public StripeEventRouter(StripeClient client, string webhookSecret, Action<object, StripeUnhandledEventNotificationEventArgs> unhandledEventHandler)
{
    this.client = client ?? throw new ArgumentNullException(nameof(client));
    this.webhookSecret = webhookSecret ?? throw new ArgumentNullException(nameof(webhookSecret));
    this.UnhandledEventHandler += new EventHandler<StripeUnhandledEventNotificationEventArgs>(unhandledEventHandler);
}

This has the added benefit of making it easy to pass anonymous functions in; but they also could do the new EventHandler... adapter call if you wanted to keep the argument the way it is.

Absent user feedback, I don't think one way is necessarily better than the other.


_dotnet_install tpv:
./scripts/dotnet-install.sh --channel {{ tpv }} --install-dir $DOTNET_ROOT
./scripts/dotnet-install.sh --channel {{ tpv }} --install-dir "$(mise where dotnet)"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Env isn't shared between recipes, so this doesn't work if you don't have the root set

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we may want to fail if DOTNET_ROOT isnt set here; iirc a lot of stuff doesnt work right if that env var isn't already set.

@xavdid-stripe xavdid-stripe changed the title add "inverted" event handler add event handler class Nov 20, 2025
@xavdid-stripe xavdid-stripe marked this pull request as ready for review November 20, 2025 00:07
@xavdid-stripe xavdid-stripe requested a review from a team as a code owner November 20, 2025 00:07
@jar-stripe
Copy link
Contributor

Not sure if the comment should go here or in your doc, but HandleUnhandledError seems like the wrong name in your example. That's just HandleUnhandledEventNotification, right?

/// </summary>
/// <param name="eventNotification">The event notification instance.</param>
/// <param name="client">The StripeClient instance.</param>
public StripeEventNotificationEventArgs(TEventNotification eventNotification, StripeClient client)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will this ever get constructed outside of our SDK code? If not, I would consider changing this constructor to be internal

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't users need it for tests? Like, if they write their function and want to test it in isolation, that function takes these args and they'd nee to be able to construct it?

Copy link
Contributor

@jar-stripe jar-stripe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Took a pass thru, it looks largely good (the names are pretty long and I left a comment in your naming doc), but I have concerns about StripeClient/ApiRequestor reuse when you swap in and out the StripeContext

/// <param name="client">The StripeClient instance.</param>
public StripeEventNotificationEventArgs(TEventNotification eventNotification, StripeClient client)
{
this.EventNotification = eventNotification ?? throw new ArgumentNullException(nameof(eventNotification));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

its a small nit, but can you pull the null check and throw into a separate line/conditional? I find that easier to read

/// <param name="client">The StripeClient instance to use for parsing and API requests.</param>
/// <param name="webhookSecret">The webhook secret used for signature verification.</param>
/// <param name="unhandledEventHandler">TODO: ADD UNHANDLED DETAILS.</param>
public StripeEventRouter(StripeClient client, string webhookSecret, EventHandler<StripeUnhandledEventNotificationEventArgs> unhandledEventHandler)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dont think I've seen any classes take an event handler as a constructor arg, but also I don't think I've seen too many patterns where an event is expected to have a handler when it is fired or else its an error. I think the shape of this is fine, but if you wanted to make it a little more "this is just a function"-like, you could do:

public StripeEventRouter(StripeClient client, string webhookSecret, Action<object, StripeUnhandledEventNotificationEventArgs> unhandledEventHandler)
{
    this.client = client ?? throw new ArgumentNullException(nameof(client));
    this.webhookSecret = webhookSecret ?? throw new ArgumentNullException(nameof(webhookSecret));
    this.UnhandledEventHandler += new EventHandler<StripeUnhandledEventNotificationEventArgs>(unhandledEventHandler);
}

This has the added benefit of making it easy to pass anonymous functions in; but they also could do the new EventHandler... adapter call if you wanted to keep the argument the way it is.

Absent user feedback, I don't think one way is necessarily better than the other.


_dotnet_install tpv:
./scripts/dotnet-install.sh --channel {{ tpv }} --install-dir $DOTNET_ROOT
./scripts/dotnet-install.sh --channel {{ tpv }} --install-dir "$(mise where dotnet)"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we may want to fail if DOTNET_ROOT isnt set here; iirc a lot of stuff doesnt work right if that env var isn't already set.

}

// Save the original context and temporarily set the event's context
var originalContext = requestor.CurrentStripeContext;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think you can safely swap out and back the StripeContext in an environment with concurrent API calls, if this.client represents the StripeClient that created the router. The EventRouter should hold its own StripeClient and requestor, if it is expected to make API calls with different context than e.g. a user's "normal" StripeClient instance.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably check this in the other SDKs too; we're not always consistent about which request objects are reused vs recreated in different scenarios.

// event-handler-dispatch: The end of the section generated from our OpenAPI spec
else
{
throw new Exception("unexpected state, please file a bug");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this message needs some love 😆

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but actually, in all seriousness could this just fall thru to the UnhandledEventHandler? at least then the user might see it and have context in which to raise an issue back to us and/or recover on their side if we have to fix something.

@jar-stripe
Copy link
Contributor

Also, we should consider adding the example from the PR description as an actual example in the project, so folks can see how to use this from code they can easily find.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants