Guide to Duffel Webhooks: Features and Best Practices
Duffel webhooks tell your booking system when something happens to a trip after checkout. An airline changes a schedule, an order fails to create, a payment succeeds or is cancelled, a cancellation is confirmed, or a stay or car booking changes, and Duffel posts the event to your endpoint so you can update your records and tell the traveller without polling the API.
This guide covers how Duffel webhooks are created, signed and tested, the events and their envelope, Duffel's delivery guarantees, and the best practices for running a Duffel receiver in production.
What are Duffel webhooks?
A Duffel webhook is a URL plus a list of event types, created through the API or the Duffel Dashboard. Each delivery is an HTTPS POST carrying one event. Duffel lists the guarantees in its API reference: at least one delivery attempt per event, "not delivered in any particular order", sent "at least once", and failed deliveries retried for 72 hours with exponential backoff.
Every event is signed. The X-Duffel-Signature header has the form t=<timestamp>,v1=<signature>, where the signature is a lowercase hex HMAC-SHA256 of the timestamp, a dot and the raw body. Duffel documents this only through the Python sample in its webhooks guide, so the details below come from that code.
The secret is easy to mishandle. Duffel's secrets look like base64, for example 54vFWvaSbbzYpxXPeB4YEw==, but the sample uses the string's own bytes as the HMAC key. Decoding it first gives the wrong key.
Duffel webhook features
| Feature | Details |
|---|---|
| Configuration | POST https://api.duffel.com/air/webhooks, or the Duffel Dashboard |
| Subscription model | One webhook per mode (live and test), each with a list of event types |
| URL rules | HTTPS only. IP addresses are refused and some hosts, such as localhost, are blocked |
| Delivery | One event per POST |
| Signature header | X-Duffel-Signature: t=<unix seconds>,v1=<hex> |
| Scheme | HMAC-SHA256, lowercase hex |
| Signed content | {t}.{raw_body} |
| Secret | Generated by Duffel, returned only on creation, used as-is |
| Replay window | Not documented. The timestamp is signed but no tolerance is given |
| Guarantees | At least once, in no particular order |
| Retries | For 72 hours, with exponential backoff |
| Tools | Ping a webhook, redeliver an event, list events by delivery success |
Duffel publishes no response timeout or source IP list for webhooks.
Common events
The webhook event schema lists 21 types, and Duffel says it will "progressively add more events":
| Area | Events |
|---|---|
| Orders | order.created, order.creation_failed, air.order.changed, order.airline_ |
| Cancellations | order_, order_ |
| Payments | air.payment.succeeded, air.payment.failed, air.payment.pending, air.payment.cancelled |
| Airline credits | air.airline_, air.airline_credit.spent, air.airline_ |
| Stays | stays.booking.created, stays.booking.cancelled, stays.booking_ |
| Cars | cars.booking.created, cars.booking.cancelled, cars.booking_ |
| Other | assistant.conversation.updated, ping.triggered |
Duffel gives no per-event descriptions, so the names are the documentation. order.airline_ is the one its guide singles out: it fires "when an order has a schedule change initiated by the airline".
Every event has the same envelope. Duffel's examples elide data.object, so here is the shape with the object reduced to its ID:
{
"id": "wev_0000A4tQSmKyqOrcySrGbo",
"api_version": "v2",
"type": "order.created",
"data": {
"object": { "id": "ord_00009hthhsUZ8W4LxQgkjo" }
},
"live_mode": true,
"idempotency_key": "ord_0000ABd6wggSct7BoraU1o",
"created_at": "2020-04-11T15:48:11.642000Z",
"identity_organisation_id": "org_0000APMFjhs4X2rJ6k7UIE"
}
id is the webhook event ID, prefixed wev_. data.object holds the resource, in the shape that resource has in Duffel's API reference. api_version is the API version that produced data. idempotency_key is described as "Duffel's unique identifier for the related object", such as an order ID, and Duffel tells you to use it to detect duplicates. live_mode tells you which mode the event came from.
Setting up Duffel webhooks
- Create the webhook with a Duffel access token. A test mode token creates a test mode webhook and a live token creates a live one:
curl -X POST --compressed "https://api.duffel.com/air/webhooks" \
-H "Accept: application/json" \
-H "Accept-Encoding: gzip" \
-H "Content-Type: application/json" \
-H "Duffel-Version: v2" \
-H "Authorization: Bearer $DUFFEL_ACCESS_TOKEN" \
-d '{
"data": {
"url": "https://example.com/webhooks/duffel",
"events": ["order.created", "order.airline_initiated_change_detected"]
}
}'
- Copy
data.secretfrom the response straight away and store it server-side, for example asDUFFEL_WEBHOOK_SECRET. Duffel says "you'll never be able to see it again". - Send a
ping.triggeredevent to check the integration end to end:
curl -X POST --compressed "https://api.duffel.com/air/webhooks/$WEBHOOK_ID/actions/ping" \
-H "Accept: application/json" \
-H "Accept-Encoding: gzip" \
-H "Duffel-Version: v2" \
-H "Authorization: Bearer $DUFFEL_ACCESS_TOKEN"
- Use
PATCH /air/webhooks/{id}to change the URL or event list, or setactivetofalse.
Duffel allows only one webhook per mode, so if several services need Duffel events, they have to share that one URL. Start in test mode, and repeat the setup with a live token when you go live. The two webhooks have separate secrets.
For local development, the Hookdeck CLI (hookdeck listen 3000 duffel --path /webhooks/duffel) gives you a public HTTPS URL that forwards to your local server, with no account required. Duffel refuses localhost, so you need a public URL like this one.
Securing Duffel webhooks
Parse X-Duffel-Signature into t and v1, build {t}.{raw_body}, compute an HMAC-SHA256 keyed with the secret string as it was returned, encode as lowercase hex and compare in constant time. Parse the header by key rather than by position.
const crypto = require('crypto');
const express = require('express');
function verifyDuffelSignature(rawBody, signatureHeader, secret) {
if (!signatureHeader || !secret) return false;
const parts = {};
for (const pair of signatureHeader.split(',')) {
const i = pair.indexOf('=');
if (i > 0) parts[pair.slice(0, i).trim()] = pair.slice(i + 1).trim();
}
if (!parts.t || !parts.v1) return false;
// The secret looks like base64, but it is used as-is. Don't decode it.
const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.`).update(rawBody).digest('hex');
const received = Buffer.from(parts.v1.toLowerCase(), 'utf8');
const computed = Buffer.from(expected, 'utf8');
return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}
const app = express();
app.post('/webhooks/duffel', express.raw({ type: '*/*' }), (req, res) => {
if (!verifyDuffelSignature(req.body, req.get('x-duffel-signature'), process.env.DUFFEL_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
// Route on event.type, deduplicate, then process asynchronously.
res.sendStatus(200);
});
The same check in Python:
import hashlib
import hmac
import os
def verify_duffel_signature(raw_body: bytes, signature_header, secret: str) -> bool:
if not signature_header or not secret:
return False
parts = dict(p.strip().split("=", 1) for p in signature_header.split(",") if "=" in p)
timestamp, received = parts.get("t"), parts.get("v1")
if not timestamp or not received:
return False
signed = timestamp.encode("utf-8") + b"." + raw_body
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(received.lower().encode("utf-8"), expected.encode("utf-8"))
# In FastAPI: raw_body = await request.body()
# verify_duffel_signature(raw_body, request.headers.get("x-duffel-signature"),
# os.environ["DUFFEL_WEBHOOK_SECRET"])
To check your verifier, use the secret 54vFWvaSbbzYpxXPeB4YEw== (the example secret from Duffel's guide, used as a plain string) and this exact body:
{"id":"wev_0000A4tQSmKyqOrcySrGbo","api_version":"v2","type":"order.created","data":{"object":{"id":"ord_00009hthhsUZ8W4LxQgkjo"}},"live_mode":false,"idempotency_key":"ord_00009hthhsUZ8W4LxQgkjo"}
The header should be:
t=1616202842,v1=fd837afdd388353f3d4f6d14cbaeaf15a4a8f86e77755c79350863b89f093d85
The timestamp is signed, so you can reject old requests in your own receiver if you want to. Duffel does not suggest a tolerance, and its sample does not check one. If you add one, keep it generous, and remember that a request stored and forwarded later by a proxy or queue keeps its original t.
Duffel's sample returns a success response even when the signature fails. Return a 401 instead, so a misconfigured secret shows up as failed deliveries rather than silently dropped events.
Make Duffel webhooks production-ready. Hookdeck Event Gateway verifies
X-Duffel-Signaturewith your secret as-is, accepts each event the moment it arrives, and durably queues it so an airline schedule change is never lost to an outage on your side.
Duffel webhook limitations and pain points
A base64-looking secret that must not be decoded
The Problem: Every signature fails, though the secret is copied exactly.
Why It Happens: Duffel's secrets look like base64 strings, padding included, and many HMAC examples decode secrets like that before use. Duffel's sample uses the string's bytes directly (secret = b"<your-generated-webhook-secret>"), so decoding it changes the key.
Workarounds:
- Pass the secret string straight to the HMAC function.
- Test your verifier against the vector above before going live.
How Hookdeck Can Help: Hookdeck's Duffel source type uses the secret exactly as you paste it and parses t and v1 from the header, so key handling is in one tested place.
The secret is shown once
The Problem: You need the secret on a new server, and nobody saved it.
Why It Happens: Duffel returns the secret only in the create response. There is no endpoint to read it back.
Workarounds:
- Store the secret in your secrets manager as part of the same script that creates the webhook.
- If it is lost, create a new webhook for that mode, update your secret, then delete the old webhook.
How Hookdeck Can Help: Point the Duffel webhook at a Hookdeck source and keep the secret in that source's configuration. Your services then receive verified events from Hookdeck and never need the Duffel secret themselves.
Duplicates and out-of-order events
The Problem: A traveller gets two schedule change emails, or a cancellation is processed before the order it cancels.
Why It Happens: Duffel guarantees at least once delivery "in no particular order", and retries failed deliveries for 72 hours.
Workarounds:
- Deduplicate as Duffel suggests, using
idempotency_key. Since it identifies the related object, combine it withtypeso that different events about the same order are not treated as duplicates. - Before acting on an event, fetch the current state of
data.objectfrom the Duffel API rather than assuming the events arrived in sequence.
How Hookdeck Can Help: Hookdeck's deduplication can drop repeated deliveries using id, or type and idempotency_key together.
One webhook per mode
The Problem: Two services both need Duffel events, but Duffel lets you create only one live webhook.
Why It Happens: Duffel allows "one url with live_mode: true and another with live_mode: false".
Workarounds:
- Build a small router that receives everything and forwards events to the services that need them.
How Hookdeck Can Help: Hookdeck turns one Duffel webhook into many destinations. Create connections from the Duffel source to each service and use filters on type so each one gets only its events.
72 hours, then you have to look
The Problem: An outage longer than three days leaves events that were never delivered.
Why It Happens: Duffel retries failed deliveries for 72 hours.
Workarounds:
- After an outage, call
GET /air/webhooks/eventswithdelivery_success=falseto find undelivered events, andPOST /air/webhooks/events/{id}/actions/redeliverto send each one again.
How Hookdeck Can Help: Hookdeck accepts every event as soon as Duffel sends it, so Duffel's retry clock never runs out. Hookdeck then holds and retries delivery to your service, and lets you replay events after a longer outage.
Best practices
Verify against the raw body
The signature covers {t}. plus the exact body bytes. Mount express.raw({ type: '*/*' }) in Express, use Buffer.from(await request.arrayBuffer()) in a Next.js route handler, or read await request.body() in FastAPI. Parse only after the check.
Route on type, and acknowledge the rest
Switch on type and return 200 for anything you do not handle, including ping.triggered and events Duffel adds later.
Check live_mode
Make sure a test event can never change a live booking. Reject or separate events whose live_mode does not match the environment that received them.
Re-read the object before acting
Because order is not guaranteed, treat each event as a prompt to fetch the current order, payment or booking from the API, especially before contacting a traveller.
Acknowledge fast and process asynchronously
Verify, store, return 200, and do the work from a queue. See why to process webhooks asynchronously and our guide to webhook idempotency.
Conclusion
Duffel signs every event with a hex HMAC-SHA256 of {t}.{body}, keyed with a secret you must save at creation and must not decode. Verify X-Duffel-Signature against the raw bytes, return a real error when it fails, deduplicate with idempotency_key and type, re-read objects because events arrive in no particular order, and use the redeliver action for anything that outlasted the 72-hour retry window.
Hookdeck Event Gateway verifies each signature, accepts every event at once, fans one Duffel webhook out to as many services as you need and holds each event until they have processed it.
Get started with Hookdeck for free and handle Duffel webhooks reliably in minutes.