Current section
Files
Jump to
Current section
Files
README.md
# Duffel
An Elixir client for the [Duffel API](https://duffel.com/docs/api) — search,
book and manage flights.
## Installation
Add `duffel` to your list of dependencies in `mix.exs`:
<!-- x-release-please-start-version -->
```elixir
def deps do
[
{:duffel, "~> 0.1.0"}
]
end
```
<!-- x-release-please-end -->
## Getting started
Grab an access token from the [Duffel dashboard](https://app.duffel.com/) and
build a client:
```elixir
client = Duffel.new(access_token: "duffel_test_...")
```
Or configure it once and use `Duffel.new/0`:
```elixir
# 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:
```elixir
client = Duffel.new(access_token: token, receive_timeout: 30_000)
```
Every call returns `{:ok, result}` or `{:error, %Duffel.Error{}}`.
## Searching and booking flights
```elixir
# 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:
```elixir
{: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:
```elixir
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:
```elixir
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`:
```elixir
{: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:
- **Flights** — `Duffel.Schema.OfferRequest`, `Offer`, `Order`, `Slice`,
`Segment`, `Passenger` and `Payment`. A search asked for with
`view: "itineraries"` has its own schema, `Duffel.Schema.ItineraryView`,
which groups offers by itinerary and fare brand and resolves the
airlines, places and aircraft that shape names by ID.
- **Stays** — `Duffel.Schema.Stays.SearchResult`, `Accommodation`, `Room`,
`Rate`, `Quote` and `Booking`.
- **Cars** — `Duffel.Schema.Cars.Search`, `Rate`, `Quote` and `Booking`.
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:
```elixir
{: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](https://duffel.com/docs/api/overview/errors),
with `type` as an atom for pattern matching:
```elixir
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:
```elixir
{: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`](https://hexdocs.pm/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:
```elixir
: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:
```elixir
{: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:
```elixir
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
| Module | Duffel resource |
| --- | --- |
| `Duffel.OfferRequests` | Search for flights |
| `Duffel.OfferRequests.SearchParams` | Build a flight search request |
| `Duffel.PartialOfferRequests` | Multi-step (per-slice) search |
| `Duffel.BatchOfferRequests` | Batched search with polling |
| `Duffel.Offers` | Offers returned by a search, re-pricing, upsells |
| `Duffel.SeatMaps` | Seat maps for an offer |
| `Duffel.Orders` | Bookings, services, metadata, re-pricing |
| `Duffel.Orders.CreateParams` | Build an order request |
| `Duffel.Payments` | Pay for hold orders |
| `Duffel.OrderCancellations` | Two-step cancellation with refund preview |
| `Duffel.OrderChangeRequests` | Request changes to an order |
| `Duffel.OrderChangeOffers` | Offers for a change request |
| `Duffel.OrderChanges` | Apply and confirm a change |
| `Duffel.AirlineInitiatedChanges` | Handle schedule changes |
| `Duffel.AirlineCredits` | Credits issued to customer users |
| `Duffel.Webhooks` | Subscriptions + signature verification |
| `Duffel.WebhookEvents` / `Duffel.WebhookDeliveries` | Event inspection, redelivery |
| `Duffel.Airlines` / `Duffel.Airports` / `Duffel.Aircraft` / `Duffel.Cities` | Reference data |
| `Duffel.LoyaltyProgrammes` | Loyalty programme reference data |
| `Duffel.Places` | Airport/city autocomplete |
### Stays
| Module | Duffel resource |
| --- | --- |
| `Duffel.Stays.Search` | Search accommodation, fetch all rates |
| `Duffel.Stays.SearchParams` | Build a stays search request |
| `Duffel.Stays.Accommodation` | Lookup, suggestions, reviews |
| `Duffel.Stays.Quotes` | Confirm a rate before booking |
| `Duffel.Stays.Bookings` | Book, manage, cancel, payment instructions |
| `Duffel.Stays.NegotiatedRates` | Manage private rates |
| `Duffel.Stays.Brands` / `Duffel.Stays.Chains` | Reference data |
| `Duffel.Stays.LoyaltyProgrammes` | Loyalty programme reference data |
The Stays booking flow: search → `fetch_all_rates` → create a quote →
create a booking from the quote.
### Cars
| Module | Duffel resource |
| --- | --- |
| `Duffel.Cars.Search` | Search for rental cars |
| `Duffel.Cars.SearchParams` | Build a cars search request |
| `Duffel.Cars.Quotes` | Confirm a rate before booking |
| `Duffel.Cars.Bookings` | Book, retrieve, cancel |
The Cars booking flow: search → create a quote → create a booking from
the quote.
### Payments
| Module | Duffel resource |
| --- | --- |
| `Duffel.Cards` | Tokenise cards (PCI-scoped `api.duffel.cards` host) |
| `Duffel.ThreeDSecureSessions` | 3DS 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
| Module | Duffel resource |
| --- | --- |
| `Duffel.Identity.CustomerUsers` | Travellers and bookers |
| `Duffel.Identity.CustomerUserGroups` | Group users for access scoping |
| `Duffel.Identity.ComponentClientKeys` | Browser keys for Duffel UI components |
## Testing your app
The client accepts `req_options`, so you can stub HTTP with
[`Req.Test`](https://hexdocs.pm/req/Req.Test.html) — no network needed:
```elixir
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](LICENSE).