Travel

Duffel API client for Elixir. Search and book flights and hotels through a clean, idiomatic interface built on Req.

Hex.pmHex DocsLicense

Installation

Add :travel to your dependencies:

def deps do
  [
    {:travel, "~> 0.1.0"}
  ]
end

Requires a Duffel API access token. Get one at app.duffel.com.

Quick Start

# Configure the client
config = Travel.new(access_token: "duffel_test_...")

# Search for hotels in London
{:ok, response} = Travel.Stays.Search.search(config, %{
  location: %{
    geographic_coordinates: %{latitude: 51.5074, longitude: -0.1278},
    radius: 10
  },
  check_in_date: "2026-08-01",
  check_out_date: "2026-08-03",
  rooms: 1,
  guests: [%{type: "adult"}]
})

response.data.results |> Enum.each(fn result ->
  IO.puts("#{result.accommodation.name} - #{result.cheapest_rate_total_amount} #{result.cheapest_rate_currency}")
end)

# Search for flights
{:ok, response} = Travel.Flights.OfferRequests.create(config, %{
  slices: [
    %{origin: "LHR", destination: "JFK", departure_date: "2026-08-01"}
  ],
  passengers: [%{type: "adult"}]
}, %{return_offers: true})

IO.puts("Found #{length(response.data.offers)} offers")

Configuration

config = Travel.new(
  access_token: "duffel_test_...",   # required
  base_url: "https://api.duffel.com", # optional, default
  api_version: "v2",                  # optional, default
  debug: false                        # optional, default
)

Stays API

Search

Search by location or by specific accommodation IDs:

# Location-based search
{:ok, response} = Travel.Stays.Search.search(config, %{
  location: %{
    geographic_coordinates: %{latitude: 51.5, longitude: -0.1},
    radius: 10
  },
  check_in_date: "2026-08-01",
  check_out_date: "2026-08-03",
  rooms: 1,
  guests: [%{type: "adult"}, %{type: "child", age: 8}]
})

# Accommodation-based search
{:ok, response} = Travel.Stays.Search.search(config, %{
  accommodation: %{
    ids: ["acc_0000AZ2OJbCJNYH4Y2Zm5j"],
    fetch_rates: true
  },
  check_in_date: "2026-08-01",
  check_out_date: "2026-08-03",
  rooms: 1,
  guests: [%{type: "adult"}]
})

Search Results

Fetch all rates for a search result:

{:ok, response} = Travel.Stays.SearchResults.fetch_all_rates(config, "ser_123")

Quotes

Create and retrieve quotes:

# Create a quote from a rate
{:ok, response} = Travel.Stays.Quotes.create(config, "rate_123")

# Get a quote
{:ok, response} = Travel.Stays.Quotes.get(config, "quo_123")

Bookings

Create, retrieve, list, and cancel bookings:

# Create a booking
{:ok, response} = Travel.Stays.Bookings.create(config, %{
  quote_id: "quo_123",
  guests: [%{given_name: "John", family_name: "Smith"}],
  email: "john@example.com",
  phone_number: "+447700900000",
  loyalty_programme_account_number: "123456789",  # optional
  accommodation_special_requests: "Late check-in"  # optional
})

# Get a booking
{:ok, response} = Travel.Stays.Bookings.get(config, "bok_123")

# List bookings (paginated)
{:ok, response} = Travel.Stays.Bookings.list(config, %{limit: 20})

# Stream all bookings (handles pagination automatically)
Travel.Stays.Bookings.stream(config)
|> Enum.each(fn response ->
  Enum.each(response.data, fn booking ->
    IO.puts("#{booking.id} - #{booking.status}")
  end)
end)

# Cancel a booking
{:ok, response} = Travel.Stays.Bookings.cancel(config, "bok_123")

Accommodation

# Get by ID
{:ok, response} = Travel.Stays.Accommodation.get(config, "acc_123")

# List near a location
{:ok, response} = Travel.Stays.Accommodation.list(config, %{
  latitude: 51.5,
  longitude: -0.1,
  radius: 5
})

# Get suggestions
{:ok, response} = Travel.Stays.Accommodation.suggestions(config, "Hilton London")

# Get suggestions with location filter
{:ok, response} = Travel.Stays.Accommodation.suggestions(config, "Hilton", %{
  radius: 10,
  geographic_coordinates: %{latitude: 51.5, longitude: -0.1}
})

# Get reviews
{:ok, response} = Travel.Stays.Accommodation.reviews(config, "acc_123", %{limit: 10})

Brands

# List all brands
{:ok, response} = Travel.Stays.Brands.list(config)

# Get a brand
{:ok, response} = Travel.Stays.Brands.get(config, "brd_123")

Loyalty Programmes

{:ok, response} = Travel.Stays.LoyaltyProgrammes.list(config)

Flights API

Offer Requests

Create flight searches and retrieve offers:

# Create and return offers
{:ok, response} = Travel.Flights.OfferRequests.create(config, %{
  slices: [
    %{origin: "LHR", destination: "JFK", departure_date: "2026-08-01"},
    %{origin: "JFK", destination: "LHR", departure_date: "2026-08-15"}
  ],
  passengers: [
    %{type: "adult"},
    %{type: "child", age: 8}
  ],
  cabin_class: "economy"
}, %{return_offers: true})

# Get an offer request
{:ok, response} = Travel.Flights.OfferRequests.get(config, "orq_123")

# List offer requests
{:ok, response} = Travel.Flights.OfferRequests.list(config, %{limit: 20})

Offers

# List offers for an offer request
{:ok, response} = Travel.Flights.Offers.list(config, "orq_123")

# Get a specific offer
{:ok, response} = Travel.Flights.Offers.get(config, "off_123", %{
  return_available_services: true
})

# Update passenger details
{:ok, response} = Travel.Flights.Offers.update(config, "off_123", "pas_123", %{
  given_name: "John",
  family_name: "Smith",
  loyalty_programme_accounts: [%{account_number: "123456", airline_iata_code: "BA"}]
})

# Price an offer
{:ok, response} = Travel.Flights.Offers.get_priced(config, "off_123", %{
  intended_payment_methods: [%{type: "balance"}],
  intended_services: []
})

Orders

# Create an order
{:ok, response} = Travel.Flights.Orders.create(config, %{
  selected_offers: ["off_123"],
  passengers: [
    %{
      given_name: "John",
      family_name: "Smith",
      born_on: "1990-01-01",
      gender: "m",
      title: "mr",
      email: "john@example.com",
      phone_number: "+447700900000"
    }
  ],
  type: "instant",
  metadata: %{"customer_ref" => "ABC123"}
})

# Get an order
{:ok, response} = Travel.Flights.Orders.get(config, "ord_123")

# List orders
{:ok, response} = Travel.Flights.Orders.list(config, %{
  awaiting_payment: true
})

# Update order metadata
{:ok, response} = Travel.Flights.Orders.update(config, "ord_123", %{
  metadata: %{"payment_intent_id" => "pit_123"}
})

# Get available services
{:ok, response} = Travel.Flights.Orders.get_available_services(config, "ord_123")

# Add services (baggage, seats)
{:ok, response} = Travel.Flights.Orders.add_services(config, "ord_123", %{
  payment: %{type: "balance", amount: "30.00", currency: "GBP"},
  add_services: [%{id: "asr_123", quantity: 1}]
})

Payments

# Pay for a pay-later order
{:ok, response} = Travel.Flights.Payments.create(config, %{
  order_id: "ord_123",
  payment: %{type: "balance", amount: "150.00", currency: "GBP"}
})

Seat Maps

{:ok, response} = Travel.Flights.SeatMaps.get(config, %{offer_id: "off_123"})

Order Cancellations

# Create a cancellation
{:ok, response} = Travel.Flights.OrderCancellations.create(config, %{
  order_id: "ord_123"
})

# Get a cancellation
{:ok, response} = Travel.Flights.OrderCancellations.get(config, "ore_123")

# List cancellations
{:ok, response} = Travel.Flights.OrderCancellations.list(config, %{order_id: "ord_123"})

# Confirm a cancellation
{:ok, response} = Travel.Flights.OrderCancellations.confirm(config, "ore_123")

Order Changes

# Create a change request
{:ok, response} = Travel.Flights.OrderChangeRequests.create(config, %{
  order_id: "ord_123",
  slices: %{
    add: [%{origin: "LHR", destination: "CDG", departure_date: "2026-09-01"}],
    remove: [%{slice_id: "sli_123"}]
  }
})

# List change offers for a change request
{:ok, response} = Travel.Flights.OrderChangeOffers.list(config, %{
  order_change_request_id: "ocr_123"
})

# Create an order change
{:ok, response} = Travel.Flights.OrderChanges.create(config, %{
  selected_order_change_offer: "oco_123"
})

# Confirm the change (without payment)
{:ok, response} = Travel.Flights.OrderChanges.confirm(config, "orc_123")

# Confirm with payment
{:ok, response} = Travel.Flights.OrderChanges.confirm(config, "orc_123", %{
  payment: %{type: "balance", amount: "50.00", currency: "GBP"}
})

Batch Offer Requests

For long-polling searches that return offers incrementally:

# Create
{:ok, response} = Travel.Flights.BatchOfferRequests.create(config, %{
  slices: [...],
  passengers: [...]
})

# Poll for results
{:ok, response} = Travel.Flights.BatchOfferRequests.get(config, "bor_123")

Partial Offer Requests

For multi-step search flows:

{:ok, response} = Travel.Flights.PartialOfferRequests.create(config, %{
  slices: [...],
  passengers: [...]
})

{:ok, response} = Travel.Flights.PartialOfferRequests.get(config, "por_123", %{
  selected_partial_offer: "off_123"
})

# Get fares for a partial offer request
{:ok, response} = Travel.Flights.PartialOfferRequests.get_fares_by_id(config, "por_123", %{
  selected_partial_offer: "off_456"
})

Airline-Initiated Changes

# List changes for an order
{:ok, response} = Travel.Flights.AirlineInitiatedChanges.list(config, %{order_id: "ord_123"})

# Accept a change
{:ok, response} = Travel.Flights.AirlineInitiatedChanges.accept(config, "aic_123")

# Update with action taken
{:ok, response} = Travel.Flights.AirlineInitiatedChanges.update(config, "aic_123", %{
  action_taken: "accepted"
})

Airline Credits

# Create an airline credit
{:ok, response} = Travel.Flights.AirlineCredits.create(config, %{
  airline_iata_code: "BA",
  amount: "100.00",
  amount_currency: "GBP",
  code: "1234567890123",
  type: "eticket",
  issued_on: "2026-01-15",
  expires_at: "2027-01-15T00:00:00Z"
})

# Get an airline credit
{:ok, response} = Travel.Flights.AirlineCredits.get(config, "acd_123")

# List airline credits
{:ok, response} = Travel.Flights.AirlineCredits.list(config, %{user_id: "icu_123"})

Response Format

All functions return {:ok, response} | {:error, error} tuples.

Success Response

{:ok, %Travel.Types.DuffelResponse{
  data: %Travel.Stays.Types.StaysBooking{...},
  meta: %Travel.Types.PaginationMeta{limit: 20, after: "cursor_abc", before: nil},
  status: 200,
  headers: %{"x-request-id" => ["req_123"]}
}}

Error Response

{:error, %Travel.Error{
  status: 400,
  code: "invalid_request",
  message: "Field 'check_in_date' must be after today",
  title: "Bad Request",
  type: "validation_error",
  request_id: "req_123",
  documentation_url: "https://duffel.com/docs/api/errors"
}}

Pagination

List endpoints support cursor-based pagination:

# Single page
{:ok, response} = Travel.Stays.Bookings.list(config, %{limit: 20, after: "cursor_abc"})

# Auto-paginating stream
Travel.Stays.Bookings.stream(config)
|> Stream.flat_map(& &1.data)
|> Enum.to_list()

Testing

mix test

All tests use Bypass for HTTP mocking — no network or API credentials required.

License

MIT