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
.
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.