Book your first stay
Sign, search, price, build, book, and retrieve one stay end to end in curl, Python, and TypeScript.
This recipe takes you from a signed request to a retrieved booking in six
calls: sign, search, read fares, build a fare, book, and retrieve. The same
flow is shown in curl, Python, and TypeScript — copy each language's snippets
in order into one file to get a complete, runnable script for that language.
Before you start
- A registered consumer (
acme-travelbelow) with a signing secret — see
Authentication. - A traveler bearer token issued by BizAway sign-in — see
Authentication § Obtaining a token. - The travel.tech base URL, exported as
TRAVELTECH_BASE_URL(production is
https://api.travel.tech). - A REST client or runtime for your chosen language (curl, Python 3 with
httpx, or Node.js withfetch).
Every request below carries the three consumer-HMAC headers plus the
traveler bearer token. The worked signing example uses Acme Travel Ltd's
consumer slug (acme-travel) and traveler Ada Lovelace
(018f2c1a-9b3e-7c4d-8a1f-3e5b7c9d1a2f), the dataset every page in these
guides shares.
Steps
1. Sign each request
Every call below needs the three consumer-HMAC headers computed from the
canonical signing string {METHOD}\n{PATH}\n{TIMESTAMP}\n{SHA256_HEX(BODY)}.
See Authentication for the full algorithm, worked
example, and troubleshooting. Define the signing helper once and reuse it for
every step below.
curl
export TRAVELTECH_BASE_URL="https://api.travel.tech"
export CONSUMER="acme-travel"
export TRAVELTECH_CONSUMER_SECRET="<your-consumer-secret>"
export TRAVELER_JWT="<traveler-jwt>"
sign_request() {
local method="$1" path="$2" body="$3"
TIMESTAMP="$(date +%s)"
local body_hash
body_hash="$(printf '%s' "$body" | openssl dgst -sha256 | awk '{print $NF}')"
local canonical
canonical="$(printf '%s\n%s\n%s\n%s' "$method" "$path" "$TIMESTAMP" "$body_hash")"
SIGNATURE="$(printf '%s' "$canonical" | openssl dgst -sha256 -hmac "$TRAVELTECH_CONSUMER_SECRET" | awk '{print $NF}')"
}Python
import hashlib
import hmac
import json
import os
import time
import httpx
BASE_URL = os.environ.get("TRAVELTECH_BASE_URL", "https://api.travel.tech")
CONSUMER = "acme-travel"
SECRET = os.environ["TRAVELTECH_CONSUMER_SECRET"]
TRAVELER_JWT = os.environ["TRAVELER_JWT"]
def signed_headers(method: str, path: str, body: bytes) -> dict[str, str]:
timestamp = str(int(time.time()))
body_hash = hashlib.sha256(body).hexdigest()
canonical = f"{method}\n{path}\n{timestamp}\n{body_hash}"
signature = hmac.new(SECRET.encode(), canonical.encode(), hashlib.sha256).hexdigest()
return {
"TravelTech-Consumer": CONSUMER,
"TravelTech-Timestamp": timestamp,
"TravelTech-Signature": signature,
"Authorization": f"Bearer {TRAVELER_JWT}",
"Content-Type": "application/json",
}TypeScript
import crypto from "node:crypto";
const BASE_URL = process.env.TRAVELTECH_BASE_URL ?? "https://api.travel.tech";
const CONSUMER = "acme-travel";
const SECRET = process.env.TRAVELTECH_CONSUMER_SECRET!;
const TRAVELER_JWT = process.env.TRAVELER_JWT!;
function signedHeaders(method: string, path: string, body: string): Record<string, string> {
const timestamp = String(Math.floor(Date.now() / 1000));
const bodyHash = crypto.createHash("sha256").update(body).digest("hex");
const canonical = `${method}\n${path}\n${timestamp}\n${bodyHash}`;
const signature = crypto.createHmac("sha256", SECRET).update(canonical).digest("hex");
return {
"TravelTech-Consumer": CONSUMER,
"TravelTech-Timestamp": timestamp,
"TravelTech-Signature": signature,
Authorization: `Bearer ${TRAVELER_JWT}`,
"Content-Type": "application/json",
};
}2. Search for stays
POST /v1/stays/search (search_stays) streams
normalised stay solutions as Server-Sent Events. Search Milan for one adult,
four nights:
curl
BODY='{
"destination": "milan-IT",
"checkIn": "2026-02-10",
"checkOut": "2026-02-14",
"rooms": [{ "adults": 1, "childrenAges": [] }],
"bookingProfile": "business"
}'
sign_request POST /v1/stays/search "$BODY"
curl -N -X POST "$TRAVELTECH_BASE_URL/v1/stays/search" \
-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
search_body = {
"destination": "milan-IT",
"checkIn": "2026-02-10",
"checkOut": "2026-02-14",
"rooms": [{"adults": 1, "childrenAges": []}],
"bookingProfile": "business",
}
body_bytes = json.dumps(search_body).encode()
with httpx.stream(
"POST",
f"{BASE_URL}/v1/stays/search",
content=body_bytes,
headers=signed_headers("POST", "/v1/stays/search", body_bytes),
) as response:
for line in response.iter_lines():
print(line)TypeScript
const searchBody = JSON.stringify({
destination: "milan-IT",
checkIn: "2026-02-10",
checkOut: "2026-02-14",
rooms: [{ adults: 1, childrenAges: [] }],
bookingProfile: "business",
});
const searchResponse = await fetch(`${BASE_URL}/v1/stays/search`, {
method: "POST",
body: searchBody,
headers: signedHeaders("POST", "/v1/stays/search", searchBody),
});The stream emits one event: solution frame per priced property, closing
with event: done:
event: solution
data: {"id":"018f2c5a-1a2b-7c3d-8e4f-5a6b7c8d9e0a","propertyId":"018f2c5a-1a2b-7c3d-8e4f-5a6b7c8d9e0b","name":"Grand Stay Milan","city":"Milan","countryCode":"IT","rating":4,"totalPrice":{"amount":"599.60","currency":"EUR"},"isPolicyCompliant":true}
event: done
data: {}
A solution's id is the searchSolutionId for the next step, and
propertyId identifies the property. If the requested stay exceeds the
account's configured maximum nights, the stream ends with event: error
and code MAX_NIGHTS_EXCEEDED instead of done. See
search_stays for the complete frame shape.
3. Read the fares
POST /v1/stays/fares (search_stay_fares)
streams the bookable room fares for the chosen solution. Pass the same
checkIn/checkOut/rooms you searched with, plus the solution's
searchSolutionId and propertyId:
curl
BODY='{
"searchSolutionId": "018f2c5a-1a2b-7c3d-8e4f-5a6b7c8d9e0a",
"propertyId": "018f2c5a-1a2b-7c3d-8e4f-5a6b7c8d9e0b",
"checkIn": "2026-02-10",
"checkOut": "2026-02-14",
"rooms": [{ "adults": 1, "childrenAges": [] }],
"isPersonal": false
}'
sign_request POST /v1/stays/fares "$BODY"
curl -N -X POST "$TRAVELTECH_BASE_URL/v1/stays/fares" \
-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
fares_body = {
"searchSolutionId": "018f2c5a-1a2b-7c3d-8e4f-5a6b7c8d9e0a",
"propertyId": "018f2c5a-1a2b-7c3d-8e4f-5a6b7c8d9e0b",
"checkIn": "2026-02-10",
"checkOut": "2026-02-14",
"rooms": [{"adults": 1, "childrenAges": []}],
"isPersonal": False,
}
body_bytes = json.dumps(fares_body).encode()
with httpx.stream(
"POST",
f"{BASE_URL}/v1/stays/fares",
content=body_bytes,
headers=signed_headers("POST", "/v1/stays/fares", body_bytes),
) as response:
for line in response.iter_lines():
print(line)TypeScript
const faresBody = JSON.stringify({
searchSolutionId: "018f2c5a-1a2b-7c3d-8e4f-5a6b7c8d9e0a",
propertyId: "018f2c5a-1a2b-7c3d-8e4f-5a6b7c8d9e0b",
checkIn: "2026-02-10",
checkOut: "2026-02-14",
rooms: [{ adults: 1, childrenAges: [] }],
isPersonal: false,
});
const faresResponse = await fetch(`${BASE_URL}/v1/stays/fares`, {
method: "POST",
body: faresBody,
headers: signedHeaders("POST", "/v1/stays/fares", faresBody),
});Each streamed fare carries a fareId you can lock in the next step:
event: solution
data: {"fareId":"018f2c5a-1a2b-7c3d-8e4f-5a6b7c8d9e0c","totalPrice":{"amount":"599.60","currency":"EUR"},"pricePerNight":{"amount":"149.90","currency":"EUR"},"numNights":4,"isRefundable":true}
event: done
data: {}
The fare is only reserved for 30 minutes. Lock it with build (next step)
before that window closes, or you will need to search and read fares again.
See search_stay_fares for the full fare
shape.
4. Build the fare
POST /v1/stays/fares/build
(build_stay_fare) locks the chosen fareId
and returns it as JSON (201). It needs the fareId from the fares stream
and the searchSolutionId from search, for lineage:
curl
BODY='{ "fareId": "018f2c5a-1a2b-7c3d-8e4f-5a6b7c8d9e0c", "searchSolutionId": "018f2c5a-1a2b-7c3d-8e4f-5a6b7c8d9e0a", "isPersonal": false }'
sign_request POST /v1/stays/fares/build "$BODY"
curl -X POST "$TRAVELTECH_BASE_URL/v1/stays/fares/build" \
-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
build_body = {
"fareId": "018f2c5a-1a2b-7c3d-8e4f-5a6b7c8d9e0c",
"searchSolutionId": "018f2c5a-1a2b-7c3d-8e4f-5a6b7c8d9e0a",
"isPersonal": False,
}
body_bytes = json.dumps(build_body).encode()
response = httpx.post(
f"{BASE_URL}/v1/stays/fares/build",
content=body_bytes,
headers=signed_headers("POST", "/v1/stays/fares/build", body_bytes),
)
locked_fare = response.json()TypeScript
const buildBody = JSON.stringify({
fareId: "018f2c5a-1a2b-7c3d-8e4f-5a6b7c8d9e0c",
searchSolutionId: "018f2c5a-1a2b-7c3d-8e4f-5a6b7c8d9e0a",
isPersonal: false,
});
const buildResponse = await fetch(`${BASE_URL}/v1/stays/fares/build`, {
method: "POST",
body: buildBody,
headers: signedHeaders("POST", "/v1/stays/fares/build", buildBody),
});
const lockedFare = await buildResponse.json();A successful build returns the locked fare:
{
"fareId": "018f2c5a-1a2b-7c3d-8e4f-5a6b7c8d9e0c",
"totalPrice": { "amount": "599.60", "currency": "EUR" },
"pricePerNight": { "amount": "149.90", "currency": "EUR" },
"numNights": 4,
"isRefundable": true
}If the fare's 30-minute hold has lapsed, build returns 410 OFFER_EXPIRED
instead — see Edge cases below. See
build_stay_fare for the full response shape.
5. Book the stay
POST /v1/stays/bookings
(create_stay_booking) creates the
booking from the locked fareId. This call requires a unique
Idempotency-Key header — resending the same key returns the original
result instead of creating a duplicate booking (see Bookings and
trips); omitting it returns 422.
curl
BODY='{
"fareId": "018f2c5a-1a2b-7c3d-8e4f-5a6b7c8d9e0c",
"passengers": [
{ "firstName": "Ada", "lastName": "Lovelace", "type": "adult", "isHolder": true }
],
"isPersonal": false
}'
sign_request POST /v1/stays/bookings "$BODY"
curl -X POST "$TRAVELTECH_BASE_URL/v1/stays/bookings" \
-H "TravelTech-Consumer: $CONSUMER" \
-H "TravelTech-Timestamp: $TIMESTAMP" \
-H "TravelTech-Signature: $SIGNATURE" \
-H "Authorization: Bearer $TRAVELER_JWT" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 018f2c5a-1a2b-7c3d-8e4f-5a6b7c8d9e0d" \
-d "$BODY"Python
import uuid
booking_body = {
"fareId": "018f2c5a-1a2b-7c3d-8e4f-5a6b7c8d9e0c",
"passengers": [
{"firstName": "Ada", "lastName": "Lovelace", "type": "adult", "isHolder": True},
],
"isPersonal": False,
}
body_bytes = json.dumps(booking_body).encode()
headers = signed_headers("POST", "/v1/stays/bookings", body_bytes)
headers["Idempotency-Key"] = str(uuid.uuid4())
response = httpx.post(f"{BASE_URL}/v1/stays/bookings", content=body_bytes, headers=headers)
booking = response.json()TypeScript
const bookingBody = JSON.stringify({
fareId: "018f2c5a-1a2b-7c3d-8e4f-5a6b7c8d9e0c",
passengers: [
{ firstName: "Ada", lastName: "Lovelace", type: "adult", isHolder: true },
],
isPersonal: false,
});
const bookingHeaders = signedHeaders("POST", "/v1/stays/bookings", bookingBody);
bookingHeaders["Idempotency-Key"] = crypto.randomUUID();
const bookingResponse = await fetch(`${BASE_URL}/v1/stays/bookings`, {
method: "POST",
body: bookingBody,
headers: bookingHeaders,
});
const booking = await bookingResponse.json();A successful booking returns 201 with the new booking's detail. Its
status is pending immediately after creation — it advances as the
booking is confirmed:
{
"bookingId": "018f2c1e-3c4d-7e5f-9a6b-7c8d9e0f1a2b",
"vertical": "stay",
"status": "pending",
"totalPrice": { "amount": "599.60", "currency": "EUR" },
"availableOperations": ["cancel_booking"]
}See create_stay_booking for the full
response shape, and Bookings and trips for
managing the booking afterward.
6. Retrieve the booking
GET /v1/stays/bookings/{bookingId} (get_booking)
reads the booking back, refreshed from the provider:
curl
BOOKING_PATH="/v1/stays/bookings/018f2c1e-3c4d-7e5f-9a6b-7c8d9e0f1a2b"
sign_request GET "$BOOKING_PATH" ""
curl "$TRAVELTECH_BASE_URL$BOOKING_PATH" \
-H "TravelTech-Consumer: $CONSUMER" \
-H "TravelTech-Timestamp: $TIMESTAMP" \
-H "TravelTech-Signature: $SIGNATURE" \
-H "Authorization: Bearer $TRAVELER_JWT"Python
path = "/v1/stays/bookings/018f2c1e-3c4d-7e5f-9a6b-7c8d9e0f1a2b"
response = httpx.get(f"{BASE_URL}{path}", headers=signed_headers("GET", path, b""))
booking_detail = response.json()TypeScript
const detailPath = "/v1/stays/bookings/018f2c1e-3c4d-7e5f-9a6b-7c8d9e0f1a2b";
const detailResponse = await fetch(`${BASE_URL}${detailPath}`, {
headers: signedHeaders("GET", detailPath, ""),
});
const bookingDetail = await detailResponse.json();The response repeats the same status field — pending until the booking
is confirmed or cancelled — plus availableOperations, telling you which
actions (like cancel_booking) are valid next. See
get_booking for the full shape, and
Bookings and trips for the booking lifecycle.
Verify it worked
- Search ends with an
event: doneframe after one or more
event: solutionframes. - Fares ends with
event: doneafter one or more fare frames, each
carrying afareId. - Build returns
201with the lockedfareIdandtotalPrice. - Book returns
201with abookingIdandstatus: "pending". - Retrieve returns
200with the samebookingIdand currentstatus.
Edge cases
The fare expired before you built it. Build returns 410 with
OFFER_EXPIRED once the 30-minute fare hold lapses:
{
"error": {
"code": "OFFER_EXPIRED",
"message": "This fare is no longer available. Search again to get a current price.",
"details": [],
"recoveryAction": "CHANGE_SELECTION",
"retryAfterSeconds": null
}
}Search and read fares again to get a fresh fareId.
The search session expired. If you wait too long between search and
fares, fares also returns 410 OFFER_EXPIRED, with a different message —
"Search session has expired. Please refresh the search." Start again from
step 2.
The signature didn't verify. Any missing, malformed, or forged consumer
signature is rejected with 401:
{
"error": {
"code": "CONSUMER_AUTH_REQUIRED",
"message": "Consumer authentication required",
"details": [],
"recoveryAction": "CONTACT_SUPPORT",
"retryAfterSeconds": null
}
}Recheck the canonical string you signed against Authentication §
Troubleshooting — a re-serialized
body or an unsigned query string are the most common causes.
The Idempotency-Key header was missing. Book returns 422 naming the
missing header. See Bookings and trips for the
full error shape.
Related guides
- Authentication — the full consumer-HMAC signing
algorithm, worked examples, and troubleshooting. - Searching and booking stays — the complete stays
flow, including destination lookup, multi-room bookings, and children. - Bookings and trips — managing a booking after
it's created: confirm, cancel, and idempotency. - Errors — the full error
envelope shape andrecoveryActionvocabulary.
Updated 6 days ago