Building a webhooks service

I recently had the chance to work on building webhooks for PlanetScale. As part of this, I learned quite a lot about the possible security vulnerabilities that running a webhooks service can expose you too.

You might be surprised with all the risks there are!

Everything I learned is over on the PlanetScale blog. I hope you find it helpful next time you're building webhooks for your application.

Bonus: Rails webhook models

For the Rails developers, here are the models I used when building the service. The goal was to keep it as simple as possible (can always add on more later).

There are two models, one for storing the webhook. And another for storing all the events that the webhook is subscribed to.

Schema

Here's an example schema, similar to the one we are using for PlanetScale.

create_table "webhook", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
  t.string "public_id", limit: 12, null: false
  t.bigint "owner_id", null: false, unsigned: true
  t.string "url", null: false
  t.boolean "enabled", default: false, null: false
  t.string "secret", null: false
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
  t.datetime "last_sent_at"
  t.string "last_sent_result"
  t.boolean "last_sent_success"
  t.integer "failure_count", default: 0, null: false
  t.index ["owner_id", "url"], name: "index_webhook_owner_and_url", unique: true
  t.index ["public_id"], name: "index_webhook_on_public_id"
end

create_table "webhook_event", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
  t.bigint "webhook_id", null: false, unsigned: true
  t.string "event_type", null: false
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
  t.index ["webhook_id", "event_type"], name: "index_webhook_event_on_webhook_id_and_event_type", unique: true
end

Many webhooks services will store data around every hook being sent. I considered it, but didn't want to commit to storing that much data before knowing it's necessary.

The goal here was to store the minimum needed for users to understand any errors occurring with their hooks. See last_sent_result and last_sent_success.

💡
public_id is the ID format we use at PlanetScale. Learn more here.

Webhook model

This is an abbreviated version of the model we are running. But I hope it gives you a general idea on how you could structure it on your own.

This model holds the url and secret. As well as a method to enqueue the background job which delivers the webhook payload.

class DatabaseWebhook < ApplicationRecord
  belongs_to :owner, class_name: "Owner", foreign_key: :owner_id
  validate :valid_url, if: -> { url_changed? }
  validates :url, presence: true, uniqueness: { scope: :owner_id }
  encrypts :secret

  has_many :events, class_name: "WebhookEvent", dependent: :destroy

  before_create :set_secret

  scope :enabled, -> { where(enabled: true) }
  scope :with_event, ->(event_type) { joins(:events).where(events: { event_type: event_type }) }

  def enqueue_job(event_type, resource)
    raise ArgumentError, "invalid event_type" unless WebhookEvent::EVENT_TYPES.include?(event_type)
  
    WebhookJob.perform_async(id, event_type.to_s, resource.id, resource.class.name)
  end

  # This method generates the webhook payload.
  # We use blueprinter serializers for generating the JSON.
  # The mapping routes an ActiveRecord object to the correct serializer.
  def payload_for(event_type, resource)
    {
      timestamp: Time.now.to_i,
      event: event_type,
      resource: SERIALIZER_MAPPING[resource.class].render_as_hash(resource),
    }.to_json
  end

  private

  def set_secret
    self.secret = SecureRandom.hex(32)
  end
end

WebhookEvent model

This model is used for storing which events a webhook is subscribed too. The idea is that whenever one of these events is triggered within our app, the webhook will be sent.

class WebhookEvent < ApplicationRecord
  belongs_to :webhook

  WEBHOOK_TEST = "webhook.test"

  EVENT_TYPES = [WEBHOOK_TEST]
  validates :event_type, inclusion: { in: EVENT_TYPES }
end

Triggering webhooks

To make triggering a webhook easy within our code. We setup a concern that we could include in our other models.

This allows us to use trigger_webhook(event_type), and the proper background jobs will be enqueued for any webhooks subscribed to that event.

module Webhooks
  extend ActiveSupport::Concern

  # Will trigger webhooks for the given event type.
  #
  # The `resource` is always `self`. This is meant to be called from the model that owns the event type.
  # For example: `WebhookEvent::BRANCH_READY` should be triggered from a DatabaseBranch.
  def trigger_webhook(event_type)
    return unless Flipper.enabled?(:webhooks, organization)

    owner.webhooks.enabled.with_event(event_type).each do |hook|
      hook.enqueue_job(event_type, self)
    end
  end
end

It has worked out quite nicely. With just a single line within a model, we can trigger any webhook event.