Handling third-party webhooks with Rails Event Store

… and check why 5600+ Rails engineers read also this

Handling third-party webhooks with Rails Event Store

Lately, one of our clients asked us to review his Rails Event Store-based application. We helped him, as RES mentoring is one of the key fields of our professional activity.

What caught our attention was the way of handling incoming webhooks from third-party services.

In a given project, incoming requests payloads were mapped to the domain commands on the fly, resulting in publishing domain events afterward.

In fact, incoming webhooks usually inform us about things that have already happened in the past. So we should rather treat them as events, not commands.

But simply mapping them to the domain events is risky. We can not reject them, as they are facts, but we can check if they do not contradict our invariants. If they do, it can mean that we have incorrect expectations or the external service is not operating correctly.

Two event types

The advice we gave was to distinguish between two types of events:

Technical event

This event is published when a webhook is received from a third-party service. It just contains the whole payload. WebhookReceived and ConnectionSynced are good examples of such events. Below is an example of how publishing such an event can look like:

module Api
  module Webhooks
    class FooController < Api::BaseController
      before_action :authenticate_foo_token!

      protect_from_forgery with: :null_session

      def connection_synced
        publish_event_uniquely(
          Foo::ConnectionSynced.new(data: { payload: webhook_payload }),
          webhook_payload[:id_webhook_data]
        )
        head :no_content
      end

      private

      def publish_event_uniquely(event, *fields)
        uniqueness_key = [event.event_type, *fields].join("_")
        event_store.publish(event, stream_name: "$unique_by_#{uniqueness_key}", expected_version: :none)
      rescue RubyEventStore::WrongExpectedEventVersion
      end

      def webhook_payload
        params[:foo].permit!.to_h
      end

      def authenticate_budgea_token!
        credentials = FooCredential.find_by(permanent_access_token: token_from_header)
        head :unauthorized unless credentials
      end
    end
  end
end

(Read more about publish_event_uniquely pattern)

Domain event

This event is published when a webhook payload is processed, and a domain event is extracted from it. It captures the memory of something interesting, which affects the domain. UserRegistered, OrderPlaced, and InvoiceIssued are common examples. You can extract more than one domain event from a single technical event. It’s also ok to have a technical event that doesn’t result in any domain event.

Why?

External system audit log

The main benefit of having technical events is an audit log of all the events received from the external system. This is useful for debugging and troubleshooting.

What is more, if your business rules ever change, you can still reuse the payload stored in a technical event. You just don’t lose valuable data.

Improved performance

Your responses to the third-party are super fast because you don’t need to wait for the domain event to be processed synchronously. You just publish the technical event and respond immediately. Then you can process the queue asynchronously based on the processing unit’s availability and the priority of the events.

Overall, you can scale the processing of the events independently from the web server.

No need to rely on the third-party retry mechanism

Once you have a payload stored, you can process it as many times as you want. If there is a bug in your code, you can fix it and reprocess the event. Retrying on unhandled exceptions is a default mechanism of most ActiveJob queue adapters.

You can’t rely on how the third-party will act on your internal error. It may retry the request or not. It may retry it immediately, or after a few hours. It may retry it only once or many times. You don’t want to lose control over this.

You might also like