19 Jan 2024
I had to handle webhooks on most web applications that I worked on: email events (deliveries, bounces, etc), payment/subscription notifications, inbound emails, etc. They can all contain large payloads and multiple events, and they expect the app to return promptly a successful response otherwise they eventually retry.
I recommend to:
I’ve seen apps where the webhook is entirely handled in the controller. That can work for small payloads and simple logic but it doesn’t scale™. Production level payload size, data, and logic are likely to make your app take several seconds to process the webhook – that will block a web process and the service sending the webhook could time out as they tend to have short request timeouts (~5 to 10 seconds).
You also don’t control the schema of the webhook and while it should remain fairly stable nothing prevents the third-party service from updating it. For example , I ran into timestamp format changes multiple times.
So I recommend processing the webhook asynchronously and making the controller as simple as possible.
Webhooks often contain a batch of events. To prevent a failure to affect all events, I tend to make the controller parse the payload to enqueue one job per event. Your job will be responsible for processing just one event - nice!
When the logic or the payload are complex you want to have good visibility into the webhooks and to be able to re-process them.
I find persisting the raw payloads in an ActiveRecord model the best way to support this.
The controller persists the webhook payload (or webhooks events) in ActiveRecord models A job processes the webhook record The job marks the webhook record as completed or failed.
Here is a sample code:
class StripeWebhook < ApplicationModel
# id, type, payload:json, created_at, updated_at, status, details:json
enum { pending: 10, completed: 20, failed: 30, skipped: 40 }
after_create :enqueue_job
def failed!(error)
update!(status: :failed, details: { error_message: error.message, error_type: error.class.name, error_backtrace: error.backtrace })
end
def enqueue_job
StripeWebhookJob.constantize.perform_async(self)
end
end
class StripeWebhookJob < ApplicationJob
attr_reader :webhook
def perform(webhook)
@webhook = webhook
ApplicationRecord.transaction do
webhook.with_lock do
break unless webhook.pending?
execute(webhook)
rescue => e
webhook.failed!(e)
raise e
end
end
end
def execute(webhook)
# real code here
end
end
You could extract the “BackgroundJob model and job” behaviors into concerns if this is something you want to apply to other webhooks or background jobs (ex: generating a report, importing a CSV, etc).
Oh, and it’s also worth mentioning that webhooks are not always sent, received, and processed in order – so watch out for race conditions, especially when it has to do with subscriptions or payments. :)
Feel free to leave comments on GitHub.
You might also be interested in: