Authentication

Sign requests with the consumer HMAC scheme and carry the traveler bearer token.

travel.tech authenticates every /v1 request on two independent axes:

  1. Consumer HMAC (primary). Identifies the client application calling
    travel.tech (for example a partner integration or the BizAway web app). Each
    request carries a keyed HMAC-SHA256 signature computed from a shared secret
    issued to your consumer registration. This is how travel.tech gates, meters, and
    attributes traffic per client.
  2. Bearer token. Identifies the traveler on whose behalf the request is
    made. A JSON Web Token (JWT) in the Authorization header.

Authenticated calls carry both: the consumer HMAC proves which app
is calling, and the bearer token proves which traveler. travel.tech does not
issue bearer tokens itself — travelers sign in through BizAway, and your
integration presents the resulting token (see
Obtaining a token below).


Consumer HMAC (primary)

Headers

Every request must carry three headers:

HeaderValue
TravelTech-ConsumerYour registered consumer slug (for example acme-travel).
TravelTech-TimestampThe request time as Unix seconds (integer). Bounds the replay window.
TravelTech-SignatureHex-encoded HMAC-SHA256 of the canonical string (below), keyed with your consumer secret.

BizAway-operated clients are signed at the edge gateway, so those apps need
no code change. If you operate your own consumer, compute and attach these
headers yourself using the algorithm below.

Deprecated alias. The previous header names — Bizaway-Consumer,
Bizaway-Timestamp, and Bizaway-Signature — are still accepted, unchanged,
so existing integrations keep working. They will be withdrawn in a future
release; sign new integrations with the TravelTech-* names above. Mixing
the two sets in one request (for example TravelTech-Consumer with
Bizaway-Signature) is rejected as a malformed credential.

Canonical signing string

Build the exact string that gets signed by joining four fields with newline
(\n) separators, in this order:

{METHOD}\n{PATH}\n{TIMESTAMP}\n{SHA256_HEX(BODY)}
FieldRule
METHODUppercase HTTP method, for example POST or GET.
PATHRequest path only — no host, no query string. For example /v1/flights/search.
TIMESTAMPThe exact value you put in TravelTech-Timestamp, used verbatim.
SHA256_HEX(BODY)Lowercase hex SHA-256 of the raw request body bytes. For an empty body use the SHA-256 of the empty string: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.

The signature is then hex( HMAC_SHA256(secret, canonical_string) ).

Worked example

Given these inputs:

  • Secret: <YOUR_CONSUMER_SECRET>
  • Method: POST
  • Path: /v1/flights/search
  • Timestamp: 1767200400
  • Body:
{"itinerary":[{"origin":"MXP","destination":"LHR","date":"2026-02-10"}],"passengers":{"adults":1},"cabinClass":"economy"}

the body hash is
5bc856ea63e038db8f09015ce042ceaefad78c93c517b97578b54ea88a22cfca
and the canonical string that gets signed is:

POST
/v1/flights/search
1767200400
5bc856ea63e038db8f09015ce042ceaefad78c93c517b97578b54ea88a22cfca

The signature is the hex HMAC-SHA256 of that string keyed with your consumer
secret, so its value depends on your secret.

Each snippet below implements the signing algorithm. They read your secret from
the TRAVELTECH_CONSUMER_SECRET environment variable and the current clock for
TravelTech-Timestamp (which is what you want in production). To reproduce the
canonical string above, pin the timestamp to 1767200400 as noted in each
inline comment.

bash (curl + openssl)

SECRET="$TRAVELTECH_CONSUMER_SECRET"   # <YOUR_CONSUMER_SECRET>
CONSUMER='acme-travel'
METHOD='POST'
REQUEST_PATH='/v1/flights/search'
TIMESTAMP="$(date +%s)"   # for the worked example, hardcode 1767200400
BODY='{"itinerary":[{"origin":"MXP","destination":"LHR","date":"2026-02-10"}],"passengers":{"adults":1},"cabinClass":"economy"}'

BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 | awk '{print $NF}')"
CANONICAL="$(printf '%s\n%s\n%s\n%s' "$METHOD" "$REQUEST_PATH" "$TIMESTAMP" "$BODY_HASH")"
SIGNATURE="$(printf '%s' "$CANONICAL" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')"

curl -X "$METHOD" "$TRAVELTECH_BASE_URL$REQUEST_PATH" \
  -H "TravelTech-Consumer: $CONSUMER" \
  -H "TravelTech-Timestamp: $TIMESTAMP" \
  -H "TravelTech-Signature: $SIGNATURE" \
  -H "Authorization: Bearer $TRAVELER_JWT" \
  -H "Content-Type: application/json" \
  -d "$BODY"

Python

import hashlib
import hmac
import os
import time

import httpx

SECRET = os.environ["TRAVELTECH_CONSUMER_SECRET"]  # <YOUR_CONSUMER_SECRET>
CONSUMER = "acme-travel"
BASE_URL = "https://api.travel.tech"
traveler_jwt = "<traveler-jwt>"  # issued by BizAway sign-in (see "Obtaining a token")


def sign(method: str, path: str, body: bytes, timestamp: str) -> str:
    body_hash = hashlib.sha256(body).hexdigest()
    canonical = f"{method}\n{path}\n{timestamp}\n{body_hash}"
    return hmac.new(SECRET.encode(), canonical.encode(), hashlib.sha256).hexdigest()


method, path = "POST", "/v1/flights/search"
body = (
    b'{"itinerary":[{"origin":"MXP","destination":"LHR","date":"2026-02-10"}],'
    b'"passengers":{"adults":1},"cabinClass":"economy"}'
)
timestamp = str(int(time.time()))  # worked example uses "1767200400"

response = httpx.post(
    f"{BASE_URL}{path}",
    content=body,
    headers={
        "TravelTech-Consumer": CONSUMER,
        "TravelTech-Timestamp": timestamp,
        "TravelTech-Signature": sign(method, path, body, timestamp),
        "Authorization": f"Bearer {traveler_jwt}",
        "Content-Type": "application/json",
    },
)

TypeScript

import crypto from "node:crypto";

const SECRET = process.env.TRAVELTECH_CONSUMER_SECRET!; // <YOUR_CONSUMER_SECRET>
const CONSUMER = "acme-travel";
const BASE_URL = "https://api.travel.tech";
const travelerJwt = "<traveler-jwt>"; // issued by BizAway sign-in (see "Obtaining a token")

function sign(method: string, path: string, body: string, timestamp: string): string {
  const bodyHash = crypto.createHash("sha256").update(body).digest("hex");
  const canonical = `${method}\n${path}\n${timestamp}\n${bodyHash}`;
  return crypto.createHmac("sha256", SECRET).update(canonical).digest("hex");
}

const method = "POST";
const path = "/v1/flights/search";
const body = JSON.stringify({
  itinerary: [{ origin: "MXP", destination: "LHR", date: "2026-02-10" }],
  passengers: { adults: 1 },
  cabinClass: "economy",
});
const timestamp = String(Math.floor(Date.now() / 1000)); // worked example: "1767200400"

const response = await fetch(`${BASE_URL}${path}`, {
  method,
  body,
  headers: {
    "TravelTech-Consumer": CONSUMER,
    "TravelTech-Timestamp": timestamp,
    "TravelTech-Signature": sign(method, path, body, timestamp),
    Authorization: `Bearer ${travelerJwt}`,
    "Content-Type": "application/json",
  },
});

Response

A request signed and authenticated correctly starts receiving POST /v1/flights/search's normalised SSE stream of flight solutions immediately —
one event: solution frame per result, closing with event: done (see the
flights guide for the full search → fares → build → book
flow):

event: solution
data: {"id":"018f2c9a-6b3e-7f21-9d4c-2b7a5e1f3c02","origin":"MXP","destination":"LHR","departureAt":{"localAt":"2026-02-10T09:15:00","timezone":"Europe/Rome","source":"provider"},"totalPrice":{"amount":"149.90","currency":"EUR"},"cabinClass":"economy","stops":0}

event: done
data: {}

Replay window

TravelTech-Timestamp must be within a bounded skew of the server clock
(300 seconds by default). Requests whose timestamp is too far in the past or
future are rejected as stale. Always send the current time, and sign the same
value you send in the header.

Two limits of this scheme are worth knowing. The skew window bounds how long a
captured signature stays usable, but there is no nonce, so an identical request
can be replayed inside that window. And the canonical string covers the request
path but not the query string, so a captured signature for one set of query
parameters also verifies for another. Treat a signed request as replayable within
the skew window and keep the transport confidential.

Secret rotation

Your consumer secret can be rotated with zero downtime. For a bounded window
after a rotation, travel.tech accepts a signature made with either the new or the
previous secret, so you can switch signing keys without a coordinated cutover.

The window is time-bounded, not open-ended. The previous secret stops
verifying 24 hours after the rotation, whether or not another rotation has
happened. Complete your cutover to the new secret inside that window; a request
signed with the previous secret after it closes is rejected like any other bad
signature.

Rejection response

A missing, malformed, stale, unknown, or forged signature is rejected with the
standard travel.tech error envelope. A request body
larger than 16 MiB is rejected the same way, since the signature covers a hash of
the whole body and cannot be checked without it — no /v1 endpoint accepts a
body that large, so this is a safety bound rather than a limit you should meet:

{
  "error": {
    "code": "CONSUMER_AUTH_REQUIRED",
    "message": "Consumer authentication required",
    "details": [],
    "recoveryAction": "CONTACT_SUPPORT",
    "retryAfterSeconds": null
  }
}

The response is deliberately generic — it does not distinguish an unknown
consumer from a bad signature — so it cannot be used as an authentication
oracle. Status is 401.

A transient failure to read the consumer registry is a different outcome: it
returns 503 with code SERVICE_UNAVAILABLE and a recoveryAction of
RETRY_LATER. Nothing about your request was rejected, so that one is worth
retrying.


Bearer token (traveler identity)

Obtain a token through BizAway sign-in (below), then send it on every
request as Authorization: Bearer <jwt>.

curl -H "Authorization: Bearer $TRAVELER_JWT" \
     -H "TravelTech-Consumer: $CONSUMER" \
     -H "TravelTech-Timestamp: $TIMESTAMP" \
     -H "TravelTech-Signature: $SIGNATURE" \
     "$TRAVELTECH_BASE_URL/v1/profile"

Obtaining a token

travel.tech does not issue bearer tokens. Tokens are issued by BizAway's
identity platform when a traveler signs in with BizAway single sign-on, and
travel.tech verifies each token's RS256 signature against BizAway's published
signing keys before trusting any claim — so a token cannot be minted by, or
forged against, travel.tech itself.

To obtain traveler bearer tokens for your integration, use the BizAway
sign-in flow provided with your consumer registration, or contact your
BizAway integration contact.

Rejection response

A missing, expired, or invalid bearer token returns 401 Unauthorized with the
standard error envelope.


HMAC-only search (no bearer token)

Every vertical's search endpoint (POST /v1/flights/search,
POST /v1/stays/search, POST /v1/cars/search, POST /v1/trains/search,
POST /v1/trains/inbound-search) accepts a request signed with the consumer
HMAC alone, with no Authorization header at all. The request runs under
travel.tech's own service account rather than a specific traveler, so
results are not personalized to any one traveler's profile or policy.

Send the same three HMAC headers described above and omit Authorization
entirely — a present-but-invalid bearer token is still rejected, so leave
the header out rather than sending an empty or placeholder value. If both a
valid HMAC signature and a valid bearer token are present, the bearer
token's traveler identity is used, exactly as on every other route.

Every other route — fares, build, book, and every non-search endpoint —
still requires both credentials as normal.


Troubleshooting

Most signature failures come from one of these mismatches between what you sign
and what the server recomputes:

  • Query string included in the path. Sign only the path
    (/v1/flights/search), never the query string. Query parameters are not part
    of the canonical string.
  • Body re-serialized before sending. Hash the exact bytes you transmit. If
    you pretty-print, re-order keys, or change whitespace after signing, the hash
    will not match. Sign and send the identical byte sequence.
  • Timestamp not verbatim. Sign the exact TravelTech-Timestamp string you
    send. Do not reformat, pad, or round it.
  • Wrong method case. Use the uppercase method (POST, not post).
  • Empty-body hash. For GET/DELETE with no body, hash the empty string
    (e3b0c442...b7852b855), not the literal string "null" or "{}".
  • Stale timestamp. If you see rejections only for slow or retried requests,
    check clock skew against the 300-second window and re-sign on retry with a
    fresh timestamp.

Reporting issues

This page is maintained by the BizAway integration team. To request changes or
report an error, contact your BizAway integration contact. Edits made directly
in the ReadMe dashboard are overwritten on the next publish.


Did this page help you?