FlightByte

Ruby SDK

The official FlightByte gem for Ruby 3.1+. Look a flight up, watch it, and verify signed flight status webhooks with a ready Rack receiver for Rails, Sinatra and Roda, on the standard library alone.

flightbyte on RubyGems is the official Ruby client for the FlightByte flight status API. It looks flights up, watches them so every gate change, delay, departure and landing arrives as a signed webhook, and gives you a ready Rack app that verifies each delivery and mounts in Rails, Sinatra, Hanami or Roda. It uses only the standard library and runs on Ruby 3.1 to 4.0.

Source: flightbyte-ruby on GitHub, MIT license. Gem: flightbyte on RubyGems.

Install

bundle add flightbyte

Or gem install flightbyte outside a Gemfile. In a Gemfile: gem "flightbyte", "~> 1.0". You need an API key: the free plan covers three watched flights a month, no card.

Look a flight up

require "flightbyte"

client = FlightByte::Client.new(api_key: ENV.fetch("FLIGHTBYTE_API_KEY"))

flight = client.flights.get(
  carrier: "TK",
  flight_number: "1848",
  date: "2026-09-08" # local departure date at the origin
)

puts flight.status.label            # Scheduled
puts flight.departure.gate          # A11
puts flight.departure.delay_minutes # 15

Every field reads as a snake_case method, flight["departure"]["delayMinutes"] reads it by the API's own key, and flight.to_h is the JSON exactly as received. Responses are frozen objects such as FlightByte::FlightSnapshot, and they match patterns: in { status: { phase: "landed" } }. date: takes a Date too. A number that flies more than one leg that day lists every leg in alternates: pass origin: "IST" to pick one. The full snapshot is under Flights.

Watch a flight

created = client.subscriptions.create(
  carrier: "TK",
  flight_number: "1848",
  date: "2026-09-08",
  webhook_url: "https://api.example.com/hooks/flights",
  events: %w[departure.gate.changed delay.increased flight.departed],
  metadata: { bookingId: "BK-1042" },
  idempotency_key: "BK-1042" # a retry never counts twice
)

# Shown in full only now: store it where your webhook receiver can read it.
signing_secret = created.secret

A subscription is one watched flight on your plan, however many changes it sends. Ten travellers on the same flight are one watched flight when you subscribe once and pass each change on to all ten. FlightByte delivers to public HTTPS addresses only: on your machine, expose the port with cloudflared tunnel --url http://localhost:3000 or ngrok http 3000 and use that URL. client.subscriptions.send_test(id) sends a signed subscription.test whenever you want to see the path work.

The other calls: list, list_all (every page), get, update, pause, resume, delete, deliveries, events, secret and rotate_secret. Every method takes a timeout: of its own.

Receive webhooks

A receiver verifies the signature against the raw body, skips an event id it has seen, answers 2xx within ten seconds and does the work afterwards. FlightByte::Webhook::RackApp does the first and answers for you: 401 for a bad signature, 400 for a body that is not an event, 500 when your block raises, so FlightByte retries, and 200 otherwise. It needs no Rack gem, so it mounts in every Ruby web framework.

FLIGHTBYTE_RECEIVER = FlightByte::Webhook::RackApp.new(ENV.fetch("FLIGHTBYTE_WEBHOOK_SECRET")) do |event|
  next unless mark_handled(event.id) # a retry carries the same id

  FlightEventJob.perform_later(event.to_h) # answer first, work afterwards
end

Build the receiver once, in an initializer, and mount it:

# config/routes.rb
mount FLIGHTBYTE_RECEIVER, at: "/hooks/flightbyte"

In a Rails controller

Read the body with request.raw_post, not params. The signature covers the bytes as sent, and JSON parsed and serialised again no longer matches.

class FlightbyteWebhooksController < ActionController::API
  def create
    event = FlightByte::Webhook.construct_event(
      request.raw_post,
      request.headers[FlightByte::Webhook::SIGNATURE_HEADER],
      ENV.fetch("FLIGHTBYTE_WEBHOOK_SECRET")
    )
  rescue FlightByte::WebhookSignatureError
    head :unauthorized
  rescue FlightByte::Error
    head :bad_request
  else
    FlightEventJob.perform_later(event.to_h)
    head :ok
  end
end

class FlightEventJob < ApplicationJob
  def perform(payload)
    event = FlightByte::WebhookEvent.new(payload)
    case event.type
    when "flight.updated"
      event.changes.each { |change| logger.info("#{event.flight_key} #{change.type}: #{change.message}") }
    when "subscription.completed"
      logger.info("#{event.flight_key} finished")
    end
  end
end

In a Sinatra route, the raw body is request.body.read and the header is request.env[FlightByte::Webhook::SIGNATURE_ENV_KEY].

An empty secret raises an ArgumentError at once, because anyone can sign with an empty key: an unset environment variable fails at boot instead of accepting forged events. The check is HMAC-SHA256 over "<t>." + raw body, compared with OpenSSL.fixed_length_secure_compare, with a timestamp more than five minutes off rejected. Verifying webhook signatures explains each step, and Webhooks has the payload and the 23 change types.

Errors

Every failed request raises a subclass of FlightByte::Error, so you rescue the ones you can handle and let the rest through.

ErrorStatusWhat to do
BadRequestError400Fix the input; messages has a line per field. For a number that flies twice, legs lists them: pass one's origin.
AuthenticationError401The key is missing, invalid, revoked or expired.
PaymentRequiredError402No watched flights left. code is watched_flight_limit, overage_cap_reached (see resets_at) or credits_exhausted (see balance and expires_at).
PermissionDeniedError403The account is suspended, or a token lacks the scope.
NotFoundError404The flight did not operate that day, or there is no such subscription.
RateLimitError429A minute, day or month quota is used up. Wait retry_after seconds.
UpstreamError502The flight data provider failed. Retry shortly.
ServerError, UnexpectedStatusErrorotherQuote request_id to support.
ConnectionErrornoneNo answer at all: refused connection, DNS, TLS or a dropped socket, or timed_out?.
begin
  client.subscriptions.create(**params)
rescue FlightByte::BadRequestError => e
  raise if e.legs.empty?

  puts "Pick a leg: #{e.legs.map(&:origin).join(", ")}"
rescue FlightByte::PaymentRequiredError => e
  puts "No watched flights left (#{e.code}) until #{e.resets_at}"
rescue FlightByte::RateLimitError => e
  sleep(e.retry_after || 5)
  retry
end

client.last_rate_limit holds the minute, day and month windows from the latest response. Every status and code is in Errors.

Configuration

OptionDefaultMeaning
api_keyrequiredYour API key, sent as x-api-key.
base_urlhttps://api.flightbyte.dev/v1Another API base URL, such as a local one.
timeout30Seconds each request may take, redirects and body included. 0 disables it.
user_agentnonePut in front of flightbyte-ruby/<version> in the user-agent header.

Requests go through Net::HTTP, so HTTPS_PROXY, HTTP_PROXY and NO_PROXY apply.

Built for Ruby apps

  • One client, many threads. Share it between Puma threads and Sidekiq workers; create it once, in an initializer. Responses are frozen, so they can be cached and handed between threads.
  • No dependencies. Only net/http, openssl and json.
  • Secrets stay out of your logs. inspect never shows the key and masks a signing secret.
  • New values do not break old code. A phase, status or change type added later is still a plain string, and a new field reads as a method like the others. A misspelt field raises NoMethodError instead of passing for a missing value.
  • Zero is not unknown. A field the API left out reads as nil, so a delay of 0 and no reading differ, and key? tells the two apart.

Next

On this page