Skip to content
Rate Extra

· Rate Extra Editorial

HMAC signing and webhooks in travel API integrations

A bearer token proves that you hold a secret. A signature proves that this exact request came from you and was not altered. For an API that moves money from a prepaid wallet, the second is what matters.

Why signatures instead of tokens

  • A leaked token can be replayed anywhere; a signature is bound to one request, one timestamp and one nonce.
  • The body hash makes tampering with amounts or traveller data detectable.
  • Server time tolerance (300 seconds) limits how long a captured request stays usable.

Signing a request

Build the string to sign from method, path, sorted query, timestamp, nonce and body hash; compute HMAC-SHA256 with your secret; send the key ID and the signature in the Authorization header.

import crypto from "node:crypto";

const keyId = process.env.TD_KEY_ID!;
const secret = process.env.TD_SECRET!;

const method = "GET";
const path = "/v1/account";        // request path as sent to the server
const query = "";                  // sorted query string, empty if none
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = crypto.randomUUID();
const body = "";
const bodyHash = crypto.createHash("sha256").update(body).digest("hex");

const stringToSign = [method, path, query, timestamp, nonce, bodyHash].join("\n");
const signature = crypto.createHmac("sha256", secret).update(stringToSign).digest("hex");

const res = await fetch("https://api.traveldistro.com" + path, {
  headers: {
    Authorization: `TD-HMAC-SHA256 KeyId=${keyId}, Signature=${signature}`,
    "X-TD-Timestamp": timestamp,
    "X-TD-Nonce": nonce,
  },
});

Verifying webhooks

Webhooks are the same idea in reverse: the platform signs, you verify. Read the raw body, recompute the HMAC over timestamp and body, compare in constant time, reject stale timestamps, and deduplicate by delivery id.

import crypto from "node:crypto";

// TravelDistro webhook: X-TD-Webhook-Signature = "sha256=" + HMAC-SHA256(`${timestamp}.${rawBody}`, endpointSecret)
export function verifyTravelDistroWebhook(headers: Record<string, string>, rawBody: string, endpointSecret: string): boolean {
  const timestamp = headers["x-td-webhook-timestamp"];
  const signature = headers["x-td-webhook-signature"] ?? "";
  if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // stale or replayed

  const expected = "sha256=" + crypto.createHmac("sha256", endpointSecret).update(`${timestamp}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b); // constant-time compare
}

// Deduplicate by X-TD-Webhook-Id: the same id is reused on every retry of one delivery.

Common mistakes

  • Parsing JSON before verifying; the signature covers the raw bytes.
  • Comparing signatures with ===; use a constant-time comparison.
  • Reusing a nonce across retries; generate a new one per request, but keep the Idempotency-Key the same.

Licensed travel agency? Apply for API access.

Get API access