Agent skill

Duffel Webhooks Skill

Receive and verify Duffel webhooks. Use when setting up Duffel flight API webhook handlers, debugging X-Duffel-Signature verification, or handling events like order.created, order.airline_initiated_change_detected, order_cancellation.confirmed, payment.succeeded, or ping.triggered.

Install this skill

npx skills add hookdeck/webhook-skills --skill duffel-webhooks


When to Use This Skill

  • How do I receive Duffel webhooks?
  • How do I verify the X-Duffel-Signature header?
  • How do I handle order.created or order.airline_initiated_change_detected events?
  • Why is my Duffel webhook signature verification failing?
  • How do I test a Duffel webhook with ping.triggered?

Essential Code (USE THIS)

Duffel sends X-Duffel-Signature: t=<unix seconds>,v1=<lowercase hex>. v1 is HMAC-SHA256 of "<t>.<raw body>", keyed with the webhook secret string's bytes as-is. The secret looks base64 (e.g. 54vFWvaSbbzYpxXPeB4YEw==) but is not decoded. Duffel has no signature-verification SDK helper in its webhook guide, so verify manually.

Verification (Node.js)

const crypto = require('crypto');

// rawBody: Buffer of the exact request bytes (express.raw, not express.json)
function verifyDuffel(rawBody, signatureHeader, secret) {
  if (!secret || !signatureHeader) return false;
  const parts = {};
  for (const kv of signatureHeader.split(',')) {
    const i = kv.indexOf('=');
    if (i > 0) parts[kv.slice(0, i).trim()] = kv.slice(i + 1).trim();
  }
  if (!parts.t || !parts.v1) return false;
  // Optional replay window, off by default: retries and replays keep the original timestamp
  const expected = crypto.createHmac('sha256', secret)
    .update(`${parts.t}.`).update(rawBody).digest('hex');
  const a = Buffer.from(parts.v1), b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Verification (Python)

import hashlib, hmac

def verify_duffel(raw_body: bytes, signature_header: str, secret: str) -> bool:
    if not secret or not signature_header:
        return False
    parts = dict(kv.strip().split("=", 1) for kv in signature_header.split(",") if "=" in kv)
    if "t" not in parts or "v1" not in parts:
        return False
    signed = parts["t"].encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(parts["v1"].encode(), expected.encode())

Timestamp freshness is opt-in: set DUFFEL_TIMESTAMP_TOLERANCE_SECONDS to a positive number to reject older t values. The default 0 skips the check, because Hookdeck and Duffel retries and replays keep the original timestamp.

For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.

Common Event Types

The event name is the type field in the JSON body.

EventDescription
ping.triggeredSent when you call the webhook ping action (use it to test)
order.createdAn order was created
order.airline_initiated_change_detectedAn airline changed the schedule of an order
order_cancellation.confirmedAn order cancellation was confirmed
payment.succeededA payment succeeded

Duffel's guide also uses order.updated. Retries can deliver duplicates, so dedupe on idempotency_key. See references/overview.md.

For the full event reference, see Duffel receiving webhooks.

Important Headers

HeaderDescription
X-Duffel-Signaturet=<unix seconds>,v1=<lowercase hex HMAC-SHA256>

Environment Variables

DUFFEL_WEBHOOK_SECRET=your_webhook_secret   # "secret" returned by the create-webhook call (shown once)
DUFFEL_TIMESTAMP_TOLERANCE_SECONDS=0   # optional; a positive value opts in to the replay window

Live and test mode use separate webhooks, each with its own secret.

Local Development

# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 duffel --path /webhooks/duffel

Reference Materials


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 4, 2026

View on GitHub →