# 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_initiated_change_detected` |
| Cancellations | `order_cancellation.created`, `order_cancellation.confirmed` |
| Payments | `air.payment.succeeded`, `air.payment.failed`, `air.payment.pending`, `air.payment.cancelled` |
| Airline credits | `air.airline_credit.created`, `air.airline_credit.spent`, `air.airline_credit.invalidated` |
| Stays | `stays.booking.created`, `stays.booking.cancelled`, `stays.booking_creation_failed` |
| Cars | `cars.booking.created`, `cars.booking.cancelled`, `cars.booking_creation_failed` |
| Other | `assistant.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:

```json
{
  "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:

```bash
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:

```bash
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](/docs/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.

```javascript
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:

```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](/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](/docs/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](/docs/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](/docs/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](/webhooks/guides/why-implement-asynchronous-processing-webhooks) and our [guide to webhook idempotency](/webhooks/guides/implement-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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Duffel webhooks reliably in minutes.