Buildkite Webhook Example

Add an endpoint to your app to handle Webhooks from Buildkite:

post "/buildkite_webhooks" => "buildkite_webhooks#create"

BuildkiteWebhooksController handles the incoming webhook, the unsigned Webhook token.

# frozen_string_literal: true

require "openssl"
require "json"

class BuildkiteWebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token, only: :create

  before_action :verify_buildkite_request

  def create
    BuildkiteEvent.process(event: buildkite_event, body: body)

    head :ok
  rescue StandardError => exception
    Rails.logger.error(exception.message)
    head :unprocessable_entity
  end

  private

  # https://buildkite.com/organizations/-/services > Webhook to get the token
  BUILDKITE_WEBHOOK_TOKEN = ENV.fetch("BUILDKITE_WEBHOOK_TOKEN", "TOKEN").freeze

  def verify_buildkite_request
    unless request_from_buildkite?
      raise "Could not verify Buildkite request. X-Buildkite-Request: '#{buildkite_request}'"
    end
  end

  def buildkite_event
    request.headers["X-Buildkite-Event"]
  end

  def body
    JSON.parse(request.raw_post)
  end

  def request_from_buildkite?
    Rack::Utils.secure_compare(buildkite_token, BUILDKITE_WEBHOOK_TOKEN)
  end

  def buildkite_token
    request.headers["X-Buildkite-Token"].to_s
  end

  def buildkite_request
    request.headers["X-Buildkite-Request"]
  end
end

Or use this one if youโ€™re using signed Webhook signature:

# frozen_string_literal: true

require "openssl"
require "json"

class SignedBuildkiteWebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token, only: :create

  before_action :verify_buildkite_request

  def create
    BuildkiteEvent.process(event: buildkite_event, body: body)

    head :ok
  rescue StandardError => exception
    Rails.logger.error(exception.message)
    head :unprocessable_entity
  end

  private

  def verify_buildkite_request
    unless request_from_buildkite?
      raise "Could not verify Buildkite request. X-Buildkite-Request: '#{buildkite_request}'"
    end
  end

  def buildkite_event
    request.headers["X-Buildkite-Event"]
  end

  def body
    JSON.parse(request.raw_post)
  end

  def raw_body
    request.body.read
  end

  # https://buildkite.com/organizations/-/services > Webhook to get the token
  BUILDKITE_WEBHOOK_SIGNATURE = ENV.fetch("BUILDKITE_WEBHOOK_SIGNATURE", "SIGNATURE").freeze

  class SignedBuildkiteWebhook
    def self.valid?(payload, header, secret)
      timestamp, signature = get_timestamp_and_signatures(header)
      expected = OpenSSL::HMAC.hexdigest("sha256", secret, "#{timestamp}.#{payload}")
      Rack::Utils.secure_compare(expected, signature)
    end

    def self.get_timestamp_and_signatures(header)
      parts = header.split(",").map { |kv| kv.split("=", 2).map(&:strip) }.to_h
      [parts["timestamp"], parts["signature"]]
    end
  end

  def request_from_buildkite?
    SignedBuildkiteWebhook.valid?(raw_body, buildkite_webhook_header, BUILDKITE_WEBHOOK_SIGNATURE)
  end

  def buildkite_webhook_header
    request.headers["X-Buildkite-Signature"].to_s
  end

  def buildkite_request
    request.headers["X-Buildkite-Request"]
  end
end

BuildkiteEvent invokes correct background job based on the webhookโ€™s pipeline is from deploy or other pipelines:

# frozen_string_literal: true

class BuildkiteEvent
  def self.process(event:, body:)
    new(event, body).process
  end

  def initialize(event, body)
    @event = event
    @body = body
  end

  def process
    return unless event.include?("build")

    if deploy?
      BuildkiteDeployNotificationJob.perform_later(payload.build)
    else
      BuildkiteBuildNotificationJob.perform_later(payload.build)
    end
  end

  private

  attr_reader :event, :body

  def pipeline_slug
    body["pipeline"]["slug"]
  end

  def deploy?
    pipeline_slug == "deploy"
  end

  def payload
    @_payload ||= BuildkitePayload.new(body, pipeline_slug)
  end
end

In the background job, you can post any text or message you like based on Build states.

# frozen_string_literal: true

class BuildkiteBuildNotificationJob < ApplicationJob
  def perform(build)
    number = build["number"]
    state = build["state"]
    user = user_for(build)
    branch = branch_from(build)
    slug = build["pipeline"]["slug"]

    case state
    when "scheduled"
      post <<~MESSAGE
        ๐ŸŒฑ [#{slug}] Build ##{number} Scheduled by #{user} (#{branch})
      MESSAGE
    when "running"
      post <<~MESSAGE
        โšก๏ธ [#{slug}] Build ##{number} Running
      MESSAGE
    when "passed"
      post <<~MESSAGE
        ๐ŸŽ‰ [#{slug}] Build ##{number} PASSED
      MESSAGE
    when "failed"
      post <<~MESSAGE
        โ›ˆ [#{slug}] Build ##{number} Failed
        View #{build["web_url"]}
      MESSAGE
    when "canceling"
      post <<~MESSAGE
        ๐Ÿšฅ [#{slug}] Build ##{number} Canceling
      MESSAGE
    when "canceled"
      post <<~MESSAGE
        ๐Ÿ›‘ [#{slug}] Build ##{number} Canceled
      MESSAGE
    when "blocked"
      post <<~MESSAGE
        ๐Ÿšฆ [#{slug}] Build ##{number} Blocked
      MESSAGE
    when "skipped"
      post <<~MESSAGE
        ๐Ÿˆ [#{slug}] Build ##{number} Skipped
      MESSAGE
    when "not_run"
      post <<~MESSAGE
        ๐Ÿ™…๐Ÿปโ€โ™€๏ธ [#{slug}] Build ##{number} Not Run
      MESSAGE
    else
      post <<~MESSAGE
        ๐Ÿช [#{slug}] Build ##{number} #{state}
      MESSAGE
    end
  end

  private

  ChatID = "Telegram Build Chart ID"

  def user_for(build)
    build.dig("author", "name").presence ||
      build.dig("creator", "name").presence ||
      "Scheduler ๐Ÿ“ฆ"
  end

  def branch_from(build)
    build["branch"]
  end

  def post(message)
    client.send_message(chat_id: ChatID, text: message)
  end

  def client
    @_client ||= TelegramClient.new
  end
end

BuildkiteDeployNotificationJob is mostly the same:

# frozen_string_literal: true

class BuildkiteDeployNotificationJob < ApplicationJob
  def perform(build)
    number = build["number"]
    state = build["state"]
    user = user_for(build)

    case state
    when "scheduled"
      post <<~MESSAGE
        ๐Ÿ›ซ Deploy ##{number} Scheduled by #{user}
      MESSAGE
    when "passed"
      post <<~MESSAGE
        ๐Ÿš€ Deploy ##{number} Deployed successfully
        View https://juanitofatas.com/
      MESSAGE
    when "failed"
      post <<~MESSAGE
        ๐Ÿ˜“ ##{number} Failed
        View #{build["web_url"]}
      MESSAGE
    when "canceled"
      post <<~MESSAGE
        ๐Ÿ™…๐Ÿปโ€โ™€๏ธ ##{number} Canceled
      MESSAGE
    end
  end

  private

  ChatID = "Telegram Deploy Chat ID"

  def user_for(build)
    build.dig("author", "name").presence ||
      build.dig("creator", "name").presence ||
      "Scheduler ๐Ÿ“ฆ"
  end

  def post(message)
    client.send_message(chat_id: ChatID, text: message)
  end

  def client
    @_client ||= TelegramClient.new
  end
end

and TelegramClient uses telegram-bot-ruby under the hook:

# frozen_string_literal: true

require "telegram/bot"

class TelegramClient
  Token = ENV.fetch("TELEGRAM_BOT_TOKEN", "ask @botfather to get a token").freeze

  def initialize(token: Token)
    @token = token
  end

  def send_message(chat_id:, text:)
    api.send_message(chat_id: chat_id, text: text)
  end

  private

  attr_reader :token

  def api
    @_api ||= Telegram::Bot::Client.new(token).api
  end
end

Thatโ€™s it.

Check out the Buildkite Webhooks documentation and Integrations like AWS Lambda, Google Cloud Functions, IronWorker, Zapier, and Webtask. You can also use Amazon EventBridge to stream events to fail fast for your builds :)

Once you deployed a Webhook app, go to your organizationโ€™s notification settings to create a Webhook!