Duffel

An Elixir client for the Duffel API — search, book and manage flights.

Installation

Add duffel to your list of dependencies in mix.exs:

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

Getting started

Grab an access token from the Duffel dashboard and build a client:

client = Duffel.new(access_token: "duffel_test_...")

Or configure it once and use Duffel.new/0:

# config/runtime.exs
config :duffel, access_token: System.fetch_env!("DUFFEL_ACCESS_TOKEN")
client = Duffel.new()

Test mode and live mode use the same API — only the token differs. Clients are plain structs, so multi-tenant apps can hold one per Duffel account.

Duffel.new/1 also takes :base_url, :api_version, :receive_timeout and :req_options. A request waits 130 seconds for a response, which covers the 120 seconds Duffel allows order and booking creation to take. Searching is much quicker — each airline gets 20 seconds to answer by default, up to the 60 seconds supplier_timeout allows — so lower it on a client used only for searching:

client = Duffel.new(access_token: token, receive_timeout: 30_000)

Every call returns {:ok, result} or {:error, %Duffel.Error{}}.

Searching and booking flights

# 1. Search: create an offer request
{:ok, offer_request} =
Duffel.OfferRequests.create(client, %{
slices: [
%{origin: "LHR", destination: "JFK", departure_date: "2026-07-01"}
],
passengers: [%{type: "adult"}],
cabin_class: "economy"
})
# 2. Pick an offer
{:ok, page} =
Duffel.Offers.list(client,
offer_request_id: offer_request["id"],
sort: "total_amount"
)
offer = hd(page.data)
# 3. Book: create an order
{:ok, order} =
Duffel.Orders.create(
client,
%{
selected_offers: [offer["id"]],
passengers: [
%{
id: hd(offer["passengers"])["id"],
title: "ms",
given_name: "Amelia",
family_name: "Earhart",
born_on: "1987-07-24",
email: "amelia@duffel.com",
phone_number: "+442080160508"
}
],
payments: [
%{
type: "balance",
currency: offer["total_currency"],
amount: offer["total_amount"]
}
]
},
idempotency_key: "my-booking-reference"
)
order["booking_reference"]
#=> "RZPNX8"

Every POST carries an Idempotency-Key header, generated unless you pass :idempotency_key. Duffel's documentation does not describe how it treats the header, so it is a precaution rather than a guarantee — what keeps a retry from booking twice is the retry policy below.

Pagination

List endpoints return one Duffel.Page at a time:

{:ok, page} = Duffel.Orders.list(client, limit: 100)
page.data # results
page.after_cursor # pass as `after:` for the next page; nil on the last page

To walk the pages yourself, Duffel.Page.has_more?/1 and Duffel.Page.next_params/2 do the cursor bookkeeping — next_params/2 keeps your filters and returns nil on the last page:

case Duffel.Page.next_params(page, limit: 100) do
nil -> :done
params -> Duffel.Orders.list(client, params)
end

Or stream every result lazily — pages are fetched as needed:

client
|> Duffel.Orders.stream(awaiting_payment: true)
|> Enum.take(500)

Streams raise Duffel.Error on request failure.

Typed responses

Resource functions return raw string-keyed maps. When you want a struct with named fields instead, pass the map to the matching schema's from_map/1:

{:ok, order} = Duffel.Orders.get(client, "ord_123")
order = Duffel.Schema.Order.from_map(order)
order.booking_reference
#=> "RZPNX8"
# nested resources are decoded too
hd(order.slices).segments
#=> [%Duffel.Schema.Segment{...}, ...]

Schemas cover three areas:

Decoding is opt-in and shallow: fields without their own schema (such as an offer's owner airline, or a car's supplier) stay raw maps. Map over a page's data to decode a list:

{:ok, page} = Duffel.Orders.list(client)
orders = Enum.map(page.data, &Duffel.Schema.Order.from_map/1)

Error handling

Every failure comes back as a Duffel.Error, so one clause covers both a rejected request and a request that never reached Duffel. Errors from the API mirror the Duffel error schema, with type as an atom for pattern matching:

case Duffel.Orders.create(client, params) do
{:ok, order} ->
order
{:error, %Duffel.Error{type: :rate_limit_error}} ->
retry_later()
{:error, %Duffel.Error{type: :validation_error, source: source, message: message}} ->
show_field_error(source, message)
{:error, %Duffel.Error{type: :transport_error, reason: reason}} ->
# the request failed to complete: connection refused, DNS, timeout
retry_later(reason)
{:error, %Duffel.Error{request_id: request_id}} ->
# quote request_id when contacting Duffel support. It comes from the
# response body, or the x-request-id header when the body has none
log_and_fail(request_id)
end

A transport error has status: nil and keeps the underlying exception, usually a Req.TransportError, under reason.

Failures Duffel calls retryable — 408, 429, 503 and network errors — are retried automatically with backoff, honouring retry-after. 500 and 502 are not, because Duffel documents them as "you should not retry this request", and a 504 is retried only on a read, never on a POST that could book twice. When a response reports your remaining allowance, Duffel.RateLimit carries it — on the error, and on every [:duffel, :request, :stop] telemetry event, so you can slow down before Duffel starts refusing requests:

{:error, %Duffel.Error{type: :rate_limit_error, rate_limit: rate_limit}} ->
retry_in(rate_limit.retry_after_ms)

Every POST also carries an Idempotency-Key, but Duffel does not document the header, so do not treat it as a second guarantee. After a failed create, check whether the order exists before trying again.

Telemetry

Every request emits a telemetry span under the [:duffel, :request] prefix — :start, :stop and :exception events. Metadata carries :method, :path and :base_url; the :stop event also reports :status, :result (:ok or :error) and :rate_limit. Attach a handler to measure latency or log requests:

:telemetry.attach(
"duffel-logger",
[:duffel, :request, :stop],
fn _event, %{duration: duration}, meta, _config ->
ms = System.convert_time_unit(duration, :native, :millisecond)
Logger.info("duffel #{meta.method} #{meta.path} -> #{meta.status} (#{ms}ms)")
end,
nil
)

Webhooks

Manage subscriptions and verify incoming deliveries:

{:ok, webhook} =
Duffel.Webhooks.create(client, %{
url: "https://example.com/webhooks/duffel",
events: ["order.created", "order.airline_initiated_change_detected"]
})
# The signing secret is only returned on creation — store it.
webhook["secret"]

In your endpoint, verify the X-Duffel-Signature header against the raw request body before parsing:

case Duffel.Webhooks.verify_signature(signature_header, raw_body, secret) do
:ok -> handle_event(Jason.decode!(raw_body))
{:error, _reason} -> send_resp(conn, 401, "")
end

Verification uses a constant-time comparison and rejects deliveries older than 5 minutes (configurable via :tolerance).

Resources

Flights

ModuleDuffel resource
Duffel.OfferRequestsSearch for flights
Duffel.OfferRequests.SearchParamsBuild a flight search request
Duffel.PartialOfferRequestsMulti-step (per-slice) search
Duffel.BatchOfferRequestsBatched search with polling
Duffel.OffersOffers returned by a search, re-pricing, upsells
Duffel.SeatMapsSeat maps for an offer
Duffel.OrdersBookings, services, metadata, re-pricing
Duffel.Orders.CreateParamsBuild an order request
Duffel.PaymentsPay for hold orders
Duffel.OrderCancellationsTwo-step cancellation with refund preview
Duffel.OrderChangeRequestsRequest changes to an order
Duffel.OrderChangeOffersOffers for a change request
Duffel.OrderChangesApply and confirm a change
Duffel.AirlineInitiatedChangesHandle schedule changes
Duffel.AirlineCreditsCredits issued to customer users
Duffel.WebhooksSubscriptions + signature verification
Duffel.WebhookEvents / Duffel.WebhookDeliveriesEvent inspection, redelivery
Duffel.Airlines / Duffel.Airports / Duffel.Aircraft / Duffel.CitiesReference data
Duffel.LoyaltyProgrammesLoyalty programme reference data
Duffel.PlacesAirport/city autocomplete

Stays

ModuleDuffel resource
Duffel.Stays.SearchSearch accommodation, fetch all rates
Duffel.Stays.SearchParamsBuild a stays search request
Duffel.Stays.AccommodationLookup, suggestions, reviews
Duffel.Stays.QuotesConfirm a rate before booking
Duffel.Stays.BookingsBook, manage, cancel, payment instructions
Duffel.Stays.NegotiatedRatesManage private rates
Duffel.Stays.Brands / Duffel.Stays.ChainsReference data
Duffel.Stays.LoyaltyProgrammesLoyalty programme reference data

The Stays booking flow: search → fetch_all_rates → create a quote → create a booking from the quote.

Cars

ModuleDuffel resource
Duffel.Cars.SearchSearch for rental cars
Duffel.Cars.SearchParamsBuild a cars search request
Duffel.Cars.QuotesConfirm a rate before booking
Duffel.Cars.BookingsBook, retrieve, cancel

The Cars booking flow: search → create a quote → create a booking from the quote.

Payments

ModuleDuffel resource
Duffel.CardsTokenise cards (PCI-scoped api.duffel.cards host)
Duffel.ThreeDSecureSessions3DS sessions for card payments

Duffel.Cards talks to api.duffel.cards, set via :cards_base_url on the client. Card tokens are single-use and short-lived.

Identity

ModuleDuffel resource
Duffel.Identity.CustomerUsersTravellers and bookers
Duffel.Identity.CustomerUserGroupsGroup users for access scoping
Duffel.Identity.ComponentClientKeysBrowser keys for Duffel UI components

Testing your app

The client accepts req_options, so you can stub HTTP with Req.Test — no network needed:

client =
Duffel.new(
access_token: "duffel_test_fake",
req_options: [plug: {Req.Test, MyApp.DuffelStub}, retry: false]
)
Req.Test.stub(MyApp.DuffelStub, fn conn ->
Req.Test.json(conn, %{"data" => %{"id" => "ord_1"}})
end)

Documentation

Full documentation at https://hexdocs.pm/duffel.

License

BSD 2-Clause. See LICENSE.