Gareth Wilson Gareth Wilson

Guide to Duffel Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationPOST https://api.duffel.com/air/webhooks, or the Duffel Dashboard
Subscription modelOne webhook per mode (live and test), each with a list of event types
URL rulesHTTPS only. IP addresses are refused and some hosts, such as localhost, are blocked
DeliveryOne event per POST
Signature headerX-Duffel-Signature: t=<unix seconds>,v1=<hex>
SchemeHMAC-SHA256, lowercase hex
Signed content{t}.{raw_body}
SecretGenerated by Duffel, returned only on creation, used as-is
Replay windowNot documented. The timestamp is signed but no tolerance is given
GuaranteesAt least once, in no particular order
RetriesFor 72 hours, with exponential backoff
ToolsPing 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":

AreaEvents
Ordersorder.created, order.creation_failed, air.order.changed, order.airline_initiated_change_detected
Cancellationsorder_cancellation.created, order_cancellation.confirmed
Paymentsair.payment.succeeded, air.payment.failed, air.payment.pending, air.payment.cancelled
Airline creditsair.airline_credit.created, air.airline_credit.spent, air.airline_credit.invalidated
Staysstays.booking.created, stays.booking.cancelled, stays.booking_creation_failed
Carscars.booking.created, cars.booking.cancelled, cars.booking_creation_failed
Otherassistant.conversation.updated, ping.triggered

Duffel gives no per-event descriptions, so the names are the documentation. order.airline_initiated_change_detected 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

  1. 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"]
    }
  }'
  1. Copy data.secret from the response straight away and store it server-side, for example as DUFFEL_WEBHOOK_SECRET. Duffel says "you'll never be able to see it again".
  2. Send a ping.triggered event 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"
  1. Use PATCH /air/webhooks/{id} to change the URL or event list, or set active to false.

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-Signature with 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 with type so that different events about the same order are not treated as duplicates.
  • Before acting on an event, fetch the current state of data.object from 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/events with delivery_success=false to find undelivered events, and POST /air/webhooks/events/{id}/actions/redeliver to 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.