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.

Most authenticated calls carry both: the consumer HMAC proves which app
is calling, and the bearer token proves which traveler. The token-issuing
login endpoints require only the consumer HMAC (they mint the bearer token).


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>"  # obtained from the login endpoints


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>"; // obtained from the login endpoints

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.

Secret rotation

Your consumer secret can be rotated with zero downtime. During a rotation
window Mercury accepts a signature made with either the new or the previous
secret, so you can switch signing keys without a coordinated cutover. Once all
of your traffic signs with the new secret, the previous secret is retired on the
next rotation.

Rejection response

A missing, malformed, stale, unknown, or forged signature is rejected with the
standard Mercury error envelope:

{
  "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 registry outage returns 503.


Bearer token (traveler identity)

Obtain a token from the login endpoints, then send it on every subsequent
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

POST /v1/auth/login authenticates a traveler with an email and password and
returns a token. Passwordless one-time-password (OTP) sign-in is also
supported:

  • POST /v1/auth/login/otp-initiate — start the OTP flow for an email.
  • POST /v1/auth/otp — exchange the emailed code for a token.

These three endpoints require the consumer HMAC but not a bearer token —
they issue one. See the API Reference for the exact
request and response schemas.

Rejection response

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


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.