If you handle your app payments with Stripe, there's a strong chance that at some point you end up needing using their webhooks. But payments are such a sensitive matter and you certainly don't want to expose your endpoints to every pirate sailing around, now, would you?
That is why it's really important to setup secured routes without being plundered and loot by some evil corsairs! So grab a grog, and let's find out how to write a basic Stripe webhook endpoint. Following some simple recommendations, it will safely receive and process events.
Webhook security
But what is the actual risk of having a webhook endpoint exposed, cast adrift? There's two main issues :
- Forged requests : someone can mimic the same data structure and send forged requests with fake data to your endpoint.
- Replay attack : provided that your endpoint is HTTPS and not HTTP, no one but you can read the events sent by Stripe to you. They can, however, capture the encrypted data sent and re-send them again and again, which definitely could cause some harm.
Luckily enough, Stripe sends us something really useful in the Headers called STRIPE-SIGNATURE
that looks like this:
"t=1721661059,v1=ef9344dc37fe005b41d7b9d29871cfe1287b2deefbb8d3lk10b9a74b859345734"
In this header, we have two values:
-
t
, a timestamp, -
v1
, an encrypted value generated each call that will be used in our app to verify the authenticity of the event sent.
With those two values, and the help of the Stripe SDK, we can build a Stripe::Event
object in our app with the payload. This will:
- Assert the data structure sent by Stripe;
-
Decrypt the encrypted value (
v1
), so we know the event is legit; - Assert that the timestamp is from less than 5 minutes ago, to mitigate the replay attack risk. We'll see that this 5 minutes value can be modified.
Now, let's implement a Stripe webhook!
Setting up a Stripe webhook endpoint
First, add an endpoint on your Stripe dashboard ( let's call it https://www.myapp.com/payments/events
) and add an event to listen to. Let's say we want to start shipping goods to the user once we have a successful payment intent. The event we want to listen to is thus payment_intent.succeeded
.
Now let's go to our app and let's code a route with a matching controller. The stripe webhook endpoint is supposed to be a POST.
# config/routes.rb
Rails.application.routes.draw do
#...
namespace :payments do
post :events
end
end
# app/controllers/payments/events_controller.rb
module Payments
module EventsController
def create
# ...
end
end
end
Ok, great, that's a good start. We have now a controller that will listen to all of your webhooks.
Building a Stripe::Event object
Now, we actually need to transform the payload into a Stripe::Event
object to make sure that it is safe.
For the signature decryption, we'll need one last thing : the signing secret of the endpoint. You can find in the stripe dashboard, in the new webhook endpoint page that you just created. You can add it in your .env
files, under the name PAYMENT_WEBHOOK_SECRET
for example.
Then we have everything necessary to create a service to build this event. This service will accept as a parameter:
- the payload, which will be a string;
- and the signature header (with the
v1
andt
values, remember?) for the security check. We'll also need to use the stripe webhook secret.
The building of the event can raise two kind of errors in case of bad data, and it's important we handle these errors properly. So let's add a basic error handler with this in mind.
# app/services/payments/build_event_service.rb
module Payments
class BuildEventService < ApplicationService
WEBHOOK_SECRET = ENV.fetch('PAYMENT_WEBHOOK_SECRET').freeze
def call(payload:, signature_header:)
@payment_provider.construct_event(
payload,
signature_header,
secret_key,
# :tolerance argument will change the 5 minutes limit to one minute
tolerance: 60
)
rescue JSON::ParserError, Stripe::SignatureVerificationError => e
raise WebhookSecurityCheckError, e
end
end
end
Now, we have a service that will effectively build and return a Stripe::Event
object.
Let's add it in our controller! And let's take the opportunity to think about what the controller needs to respond here. Stripe will expect a 200
response from its webhook call, so let's return that, and let's maybe return a 401
if our security check didn't pass.
# app/controllers/payments/events_controller.rb
module Payments
module EventsController
def create
event = BuildEventService.new.call(payload: payload, signature_header: signature_header)
# do stuff with the event created
# ...
head :ok
rescue WebhookSecurityCheckError => e
head 401
end
private
def payload
request.body.read
end
def signature_header
request.env['HTTP_STRIPE_SIGNATURE']
end
end
end
Ok! We have now an endpoint that is all setup for a basic security check.
Dispatching Stripe events
However, this is still not entirely ideal. Stripe expects to receive an answer quickly. In fact, this controller should do the most minimal thing possible :
- check that the event is indeed sent by Stripe,
- delegate ASAP the responsability and the payload to a corresponding job.
So let's quickly add a dispatcher that will do just that. For this, we'll need to have a hash with the corresponding event types mapping to jobs.
# app/services/payments/dispatch_event_service.rb
module Payments
class DispatchEventService < ApplicationService
EVENT_TYPE_TO_JOB = {
'payment_intent.succeeded' => SendProductToClientJob,
#...
}.freeze
def call(event:)
EVENT_TYPE_TO_JOB[event.type].perform_later(event: event.to_hash)
end
end
end
Now, we can use this dispatcher in our controller:
# app/controllers/payments/events_controller.rb
module Payments
module EventsController
def create
event = BuildEventService.new.call(payload: payload, signature_header: signature_header)
DispatchEventService.new.call(event: event)
head :ok
rescue WebhookSecurityCheckError => e
head 401
end
# [...]
Testing
For testing, you'll probably need to stub your method call construct_event
to make it return what you need. And to test manually and locally the webhook, a good and easy way to do it without having to use a tool like ngrok is to use the Stripe CLI.
This is the command to install Stripe CLI via Homebrew :
brew install stripe/stripe-cli/stripe
Then login with your credentials :
stripe login
And now, you can easily forward any events necessary to your local server :
stripe listen --events payment_intent.succeeded \
--forward-to localhost:3000/payments/events
And to trigger the webhook when necessary:
stripe trigger payment_intent.succeeded
Of course, you can go further in securing your webhook. For example, you can use the idempotency key that each event provide to make sure that the event is unique.
With this basic setup, you can already receive pretty safely Stripe webhooks, and you can add events in the EVENT_TYPE_TO_JOB
whenever you need to. Your code will safely build a Stripe::Event
object and check the correct event type to delegate the attributes of the payload to a matching job.
Thank you for reading. I invite you to subscribe so you don't miss the next articles that will be released.
Happy sailing (and coding)! ๐ฆ ๐ดโโ ๏ธ
Top comments (1)
Thanks for sharing ๐๐๐๐