Authentication

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

Mercury authenticates every /v1 request on two independent axes:

  1. Consumer HMAC (primary). Identifies the client application calling
    Mercury (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 Mercury 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. Mercury 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
Bizaway-ConsumerYour registered consumer slug (for example acme-travel).
Bizaway-TimestampThe request time as Unix seconds (integer). Bounds the replay window.
Bizaway-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.

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 Bizaway-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: demo-consumer-secret-do-not-use-in-prod
  • 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 resulting signature is
1db2f36a14fe7c5bf8008e1eb933239125ac876abf3300d3ddfee9231c2265a6.

Each snippet below implements the signing algorithm. For a real request they
read the current clock for Bizaway-Timestamp (which is what you want in
production). To reproduce the exact signature value above, pin the timestamp to
1767200400 as noted in each inline comment.

bash (curl + openssl)

SECRET='demo-consumer-secret-do-not-use-in-prod'
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" "$MERCURY_BASE_URL$REQUEST_PATH" \
  -H "Bizaway-Consumer: $CONSUMER" \
  -H "Bizaway-Timestamp: $TIMESTAMP" \
  -H "Bizaway-Signature: $SIGNATURE" \
  -H "Authorization: Bearer $TRAVELER_JWT" \
  -H "Content-Type: application/json" \
  -d "$BODY"

Python

import hashlib
import hmac
import time

import httpx

SECRET = "demo-consumer-secret-do-not-use-in-prod"
CONSUMER = "acme-travel"
BASE_URL = "https://api.example.com"
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={
        "Bizaway-Consumer": CONSUMER,
        "Bizaway-Timestamp": timestamp,
        "Bizaway-Signature": sign(method, path, body, timestamp),
        "Authorization": f"Bearer {traveler_jwt}",
        "Content-Type": "application/json",
    },
)

Node.js

import crypto from "node:crypto";

const SECRET = "demo-consumer-secret-do-not-use-in-prod";
const CONSUMER = "acme-travel";
const BASE_URL = "https://api.example.com";
const travelerJwt = "<traveler-jwt>"; // issued by BizAway sign-in (see "Obtaining a token")

function sign(method, path, body, timestamp) {
  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"

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

Replay window

Bizaway-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, Mercury 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 Mercury 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 "Bizaway-Consumer: $CONSUMER" \
     -H "Bizaway-Timestamp: $TIMESTAMP" \
     -H "Bizaway-Signature: $SIGNATURE" \
     "$MERCURY_BASE_URL/v1/profile"

Obtaining a token

Mercury does not issue bearer tokens. Tokens are issued by BizAway's
identity platform when a traveler signs in with BizAway single sign-on, and
Mercury 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, Mercury 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.


Unauthenticated routes

GET /v1/app/update-check is the one /v1 route that does not require a
bearer token. It is called at app bootstrap, before a traveler has signed
in, so there is no traveler identity to present yet. Consumer HMAC signing
still applies as normal — this exemption only drops the bearer-token
requirement.


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 Bizaway-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?