The booking API
Read trips, departures, guides and destinations, price a booking and take it on your own website, with payment through Stripe's Payment Element and your organisation's Stripe account.
Reference15 min read
The booking API lets your own website show trips with live prices and places, and take bookings without sending travellers to the hosted booking pages. It needs no key: everything it returns is what the booking pages show anyway, and payments go straight from the traveller's browser to your Stripe account. An API key is optional: it lets your server read draft trips for a preview, and counts each key's requests.
All addresses start with /travel/api/public/ and your booking name from Booking settings, such as:
https://app.bifrostorbit.com/travel/api/public/northwildsAnswers are JSON. A successful answer has the result in data; an error has error instead, described under Errors below.
The reads are also one GraphQL endpoint, for pages that need several things in one request.
Languages
Every endpoint that reads the organisation, trips or destinations takes ?locale=, such as ?locale=nb. Use one of the languages in Booking settings. Without it, or with a language your organisation doesn't offer, answers are in your main language.
Each answer says which language it is in with locale, and lists in fallback the fields that aren't translated yet and came from your main language, so you can mark them or leave them as they are:
{ "name": "Eggevandring i Lofoten", "locale": "nb", "fallback": ["description", "highlights.2", "itinerary.day-3.body", "images.0"]}Fields in lists are named by position, such as highlights.2, itinerary days, packages, extras and questions by their id, such as itinerary.day-3.body, and image descriptions by the image's position, such as images.0. For a trip, fallback also names guides and places to stay, such as guides.6650f1c2a9e4b3d2c1f0ea10.bio. In lists of trips and destinations, every item has its own locale and fallback.
Pass the traveller's language as locale in quotes and bookings too. Quotes then name packages and extras in that language, and the booking keeps the language, so every email to the traveller is written in it.
To try any endpoint with your own trips and departures, open the API playground from Developers in the sidebar. API response on the overview, trips, departures, bookings, discount codes and booking settings shows the answer for that page.
Which websites can call it
https://northwilds.com, and the booking pages themselves. Others get 403.| Endpoints | Who can call them from a browser |
|---|---|
| Reading the organisation, trips and destinations | Any website. |
| Quotes and bookings | Only the websites listed under Allowed websites in Booking settings, such as https://northwilds.com, and the booking pages themselves. Others get 403. |
Calls from a server, without an Origin header, are always allowed; bookings made that way are marked as coming from the API rather than from a website. Answers carry Vary: Origin, and preflight (OPTIONS) answers can be cached for 10 minutes.
A key that reads drafts (trv_sk_) works only from a server or the booking pages. From any other website it gets 403. See API keys.
The organisation
GET/travel/api/public/northwilds
Returns what a booking page needs around the trips: slug, brandName, logoUrl, colour, contactEmail, termsUrl, the organiser (legalName, registration, address), the travel guarantee (provider, text), locales (the languages you offer, the main one first), defaultLocale, locale, fallback and payments: online says whether online payment works right now, and publishableKey is the Stripe key to start the Payment Element with.
Trips
GET/travel/api/public/northwilds/trips
Lists published, public trips, oldest first, in the order they were created. Filter with activity, country (two letters, such as NO) or category, or ask for chosen trips with ids= and up to 100 trip ids separated by commas; that also returns unlisted trips.
Each trip has id, slug, name, draft, category, tags, activities, countries, areas and area (the same areas joined with commas, such as "Lofoten"), summary, badge, days, difficulty, groupSize (min, max), images, seo (title, description, image), currency, priceFrom, currencies, pricesFrom, rating (average, count), bookingMode (instant, request or closed), nextDeparture, cmsEntryId, departures, locale and fallback.
Each departure has id, startDate, endDate, flexible (dates to be decided), guaranteed, status (open, full or closed), seatsLeft and capacity (null for no limit), prices (by package id), priceFrom, pricesByCurrency, priceFromByCurrency and bookingDeadline. Departures in the past, unless their dates are still to be decided, and cancelled ones are left out.
Trip lists can be cached for 30 seconds, unless you read drafts.
Images
Every image, on trips, guides and places to stay, has id, url, mimeType, width, height, alt, title and focalPoint (x, y, or null). Images from Travel Orbit's media library take ?w= for a width and ?format= for webp, avif or jpeg, such as ?w=960&format=avif. The width is rounded up to the nearest of 320, 480, 640, 768, 960, 1280, 1600, 1920 and 2400 pixels. Image answers can be cached for a year.
Amounts are whole minor units: 124000 with EUR is €1,240.00.
Currencies
A trip can be sold in more than one currency, each with its own prices. currency is the trip's main currency, and priceFrom and prices are in it. currencies lists every currency the trip is sold in, the main one first, such as ["EUR", "NOK"]. pricesFrom and the fields ending in ByCurrency hold the amounts in every currency, keyed by currency:
{ "currency": "EUR", "priceFrom": 124000, "currencies": ["EUR", "NOK"], "pricesFrom": { "EUR": 124000, "NOK": 1450000 }}Each departure's pricesByCurrency has its prices by currency and then by package id, such as { "NOK": { "per-person": 1450000 } }, and priceFromByCurrency its lowest price in each currency.
One trip
GET/travel/api/public/northwilds/trips/lofoten-ridge-walk
When a trip's address has changed, its old address still answers, with the current slug in the answer. Everything in the list, plus description, highlights, included, notIncluded, itinerary (each day with its place to stay as hotel: name, location, website, rating, and its kind of day as kind, the key such as arrival or summit-day, with kindLabel, the name in the chosen language, and kindIcon, the name of its icon, and its meals as meals, the keys such as breakfast or packed-lunch, with mealLabels, their names in the chosen language in the same order, and how travellers get around as transport), packages (id, name, description, price, pricesByCurrency, occupancy, capacity), addOns (id, name, description, price, pricesByCurrency, per, capacity, deadlineDays, icon), paymentPlan, paymentPlanByCurrency (the plan with the deposit in each currency), cancellation, questions, travellerFields, travellers (min, max), guides (name, slug, role, bio, photo, image, languages), stays (each place to stay on the itinerary with id, name, location, country, website, rating, description, images and nights), terms, brochureUrl and invitationFor (who a private trip is for, such as Bergen Climbing Club, and null for other trips).
Each day's transport lists the legs of travel in the order travellers take them, such as a flight, then a ferry, then a bus. Each leg has id, mode (flight, train, bus, ferry, boat, car, transfer, walk or other), modeLabel (its name in the chosen language, such as "Ferje" in Norwegian), from and to (the places, in the chosen language), fromCode and toCode (three-letter airport codes for flights, otherwise null), operator, number (the flight, train or route number), departs and arrives (local times like 07:15), duration (in minutes) and note. Any of the last six can be null:
{ "id": "bodo-moskenes", "mode": "ferry", "modeLabel": "Ferry", "from": "Bodø", "to": "Moskenes", "fromCode": null, "toCode": null, "operator": "Torghatten Nord", "number": null, "departs": null, "arrives": null, "duration": 195, "note": null }Days also keep flights, with only the flights from transport as from, to (airport codes, such as OSL and BOO) and number, so older sites keep working. Use transport for anything new.
Use questions and travellerFields to build the booking form. See The booking form.
Guides
GET/travel/api/public/northwilds/guides?locale=en
Lists your guides who lead a public trip or have a bio, by name. Each has slug (made from the name, such as kari-nordmann), name, role, bio, image, languages, profile (home, since, quote, story, approach, loves, favourites, pickWhy), tripCount, seo, locale and fallback.
GET/travel/api/public/northwilds/guides/kari-nordmann
One guide: everything in the list, plus trips (the public trips they lead, as in the trips list) and pick (the trip they recommend, or null). A guide's old address still answers after a change of name. Guide answers can be cached for 60 seconds.
Destinations
GET/travel/api/public/northwilds/destinations?locale=en
Lists the continents and countries your public trips visit, plus countries you've published without trips. Each continent has key (such as europe), name, published, tagline, cover and countries. Each country has code (such as NO), slug (such as norway), name, flag (the address of a round SVG flag), tripCount, published, tagline and cover (url, alt).
locale sets the language of your own text, as for trips, and of names, labels and facts. The built-in facts come in English, Norwegian and German: nb and nn get Norwegian facts, de German, and every other language English. Without locale, answers are in your main language. The older lang parameter still works.
GET/travel/api/public/northwilds/destinations/europe?locale=en
One continent: everything in the list, plus content (tagline, intro, guide, images, bestTime, entry, seo) once you publish its introduction, otherwise null.
GET/travel/api/public/northwilds/destinations/europe/norway?locale=nb
One country, by its slug or its two-letter code:
facts:name,officialName,continent(key,name),capital,languages,religion,vaccines,drivingSide(value,label),callingCode,currency(code,name),timeZone,capitalAtandcentre(lat,lng) andmapId(the numeric ISO code, for world maps)labels: the tile headings in the chosen language, such asHovedstadforcapitalcontent: your own text, as for a continent, ornulluntil you publish the countrytrips: the published, public trips that visit it, as in the trips listlive:timeZone,weather(temperaturein °C,symbol,description,icon,at,credit) andrate(fromyour currency,tothe country's,rate,date).weatherisnullwhen there's no forecast, andrate.rateisnullwhen the European Central Bank has no rate for the currency.
Show live.weather.credit, such as "Weather data from MET Norway", wherever you show the weather. A country with neither trips nor published text answers 404. Destination answers can be cached for 5 minutes.
Country facts come from REST Countries (MPL-2.0) and the IANA time zone database (public domain), weather from MET Norway (CC BY 4.0) and exchange rates from the European Central Bank. See Destinations.
Price a booking
POST/travel/api/public/northwilds/quote
{ "departureId": "6650f1c2a9e4b3d2c1f0ea07", "travellers": [{ "packageId": "per-person" }, { "packageId": "per-person" }], "addOns": [{ "id": "gear-hire", "quantity": 2 }], "discountCode": "SPRING15", "currency": "NOK", "locale": "nb"}currency is optional and defaults to the trip's main currency. Pass the currency your website shows when the trip's currencies include it, such as NOK on a Norwegian page. A currency the trip isn't sold in gets 400 with the message This trip isn't sold in SEK.
Returns currency, lines (id, kind: package, addOn or discount, refId, label, unitAmount, quantity and amount), subtotal, discount, total, code, schedule (each payment's label, dueDate and amount), dueNow (what is paid at checkout) and seatsLeft. Nothing is saved, so call it as often as the traveller changes their choices.
Make a booking
POST/travel/api/public/northwilds/bookings
{ "departureId": "6650f1c2a9e4b3d2c1f0ea07", "lead": { "firstName": "Ingrid", "lastName": "Berg", "email": "ingrid@northwilds.com", "phone": "+47 912 34 567" }, "travellers": [ { "firstName": "Ingrid", "lastName": "Berg", "packageId": "per-person", "dietary": "Vegetarian", "answers": { "boot-size": "EU 39" } }, { "firstName": "Lars", "lastName": "Berg", "packageId": "per-person", "answers": { "boot-size": "EU 44" } } ], "addOns": [{ "id": "gear-hire", "quantity": 2 }], "discountCode": "SPRING15", "answers": { "heard-from": "A friend" }, "message": "We arrive in Bodø the evening before.", "currency": "EUR", "locale": "nb", "acceptTerms": true}A booking takes from 1 to 24 travellers. currency works as in the quote: the booking, its schedule and the payment are all in it, and the traveller pays exactly the trip's price in that currency. Each traveller can also have email, phone, dateOfBirth (such as 1988-04-12), nationality, passport, address and emergencyContact; the trip's travellerFields say which are required. answers hold the answers to the trip's questions by question id: per-traveller questions on each traveller, the rest on the booking. acceptTerms must be true.
It answers 201 with:
{ "data": { "booking": { "id": "6650f1c2a9e4b3d2c1f0eb42", "reference": "NO-7KQ2MX", "status": "held", "total": 225250, "currency": "EUR", "schedule": [{ "id": "deposit", "label": "Deposit", "dueDate": "2027-01-15", "amount": 45050, "status": "due" }], "holdExpiresAt": "2027-01-15T10:15:00.000Z" }, "payment": { "kind": "payment", "clientSecret": "pi_…_secret_…", "publishableKey": "pk_live_…", "amount": 45050, "currency": "EUR" } }}Here two travellers on a May departure with gear hire and SPRING15 cost €2,252.50, with a €450.50 deposit due now. The schedule above is shortened; it lists every payment.
Take the payment
When payment is there, show Stripe's Payment Element with publishableKey and clientSecret, and confirm it in the browser:
kind: "payment": confirm withstripe.confirmPayment. The amount is what is due now, usually the deposit.kind: "setup": nothing is due today, so confirm withstripe.confirmSetupto save the traveller's card for the later payments.
The card details go straight to Stripe, never to Travel Orbit or your server. Travel Orbit confirms the booking when Stripe tells it the payment succeeded, and emails the traveller.
Holds
A booking with status: "held" keeps its places until holdExpiresAt, 15 minutes unless the organisation changed it. If the payment isn't finished by then, the booking expires and the places go back on sale. Don't make a second booking when a payment fails: let the traveller try again with the same clientSecret.
Check a booking after payment
POST/travel/api/public/northwilds/bookings/NO-7KQ2MX/check
{ "email": "ingrid@northwilds.com" }Send the reference in the address and the lead traveller's email in the body. The answer is the booking in the same shape as above, without payment. When the booking is still held or has expired, Travel Orbit asks Stripe first, so a payment that went through is confirmed straight away. Use it on your confirmation page after stripe.confirmPayment returns. An unknown reference, or an email that doesn't match, answers 404.
Requests
When the trip's bookingMode is request, or the organisation's payments.online is false, the booking comes back with status: "requested" and payment: null. Nothing is charged and no places are taken: tell the traveller the organisation will be in touch. Trips with bookingMode: "closed" refuse bookings.
The traveller account
Websites can offer travellers their own account, as Northwilds does, through the portal endpoints under /travel/api/public/{slug}/portal/. Set Traveller account page in Booking settings to your page, and the links in traveller emails go there.
/portal/codeemail, optional locale{ "sent": true }, but only sends to an email that made a confirmed, requested or cancelled booking, and at most once every 45 seconds./portal/verifyemail, codetravel.portal session cookie for 12 hours./portal/verify/portal/bookings?locale=reference, tripName, tripSlug, image, days, departureDate, status, travellers, payments, lines, currency, total, paid, schedule, autoCharge and cardSaved./portal/payreference, instalments: "next", "all" or a list of instalment idspayment, as when booking./portal/auto-chargereference, on/portal/messages?reference=/portal/messagesreference, body| Endpoint | Body or query | Does |
|---|---|---|
POST/portal/code | email, optional locale | Emails a 6-digit code, valid for 10 minutes. Always answers { "sent": true }, but only sends to an email that made a confirmed, requested or cancelled booking, and at most once every 45 seconds. |
POST/portal/verify | email, code | Checks the code (5 tries) and sets the travel.portal session cookie for 12 hours. |
DELETE/portal/verify | Signs out. | |
GET/portal/bookings | optional ?locale= | The signed-in traveller's bookings: reference, tripName, tripSlug, image, days, departureDate, status, travellers, payments, lines, currency, total, paid, schedule, autoCharge and cardSaved. |
POST/portal/pay | reference, instalments: "next", "all" or a list of instalment ids | Starts a payment for what is due and answers with payment, as when booking. |
POST/portal/auto-charge | reference, on | Turns automatic payments from the saved card on or off. |
GET/portal/messages | ?reference= | The conversation about one booking. |
POST/portal/messages | reference, body | Sends the traveller's message to your team. |
The session cookie belongs to the Travel Orbit address, with HttpOnly, SameSite=Lax and the path /travel, so a browser on your website won't send it on its own. Call the portal endpoints from your own server: keep the value of travel.portal from the verify answer in your own cookie, and send it back as Cookie: travel.portal=… on the next requests. A missing or expired session answers 401.
POST/portal/travellers updates one traveller's details on a booking of the signed-in traveller, until the trip starts:
{ "reference": "NO-PABH4W", "travellerId": "t1", "changes": { "passport": "NO1234567", "dietary": "Vegetarian", "dateOfBirth": "1988-04-12" } }Only phone, dateOfBirth, nationality, passport, dietary and emergencyContact can be changed; names and the travellers themselves are changed by the organisation. The answer is the booking's travellers as they are now.
Errors
Errors have a status and the same shape:
{ "error": { "code": "invalid", "message": "Add the passport number for traveller 2" } }400invalid403forbidden404not_found409conflict422invalidissues lists each one.401unauthorized429rate_limited500server_error| Status | Code | Why |
|---|---|---|
400 | invalid | The booking can't be made as asked, such as a code that has expired or a departure that has closed. The message says why, in words you can show the traveller. |
403 | forbidden | The website isn't in the allowed websites. |
404 | not_found | No organisation with that booking name, or no published trip with that address. |
409 | conflict | Not enough places left, or a package or extra sold out. |
422 | invalid | A field is missing or in the wrong format. issues lists each one. |
401 | unauthorized | An API key that is wrong, revoked, expired or for another organisation, or a portal request without a signed-in traveller. |
429 | rate_limited | Too many requests from one address in a short time. Wait a minute. |
500 | server_error | Something went wrong on our side. Try again in a moment. |
Rate limits
Limits count per calling address and organisation. Each allows a short burst, then refills at the rate shown:
| Requests | Burst | Then |
|---|---|---|
| Reading the organisation, trips, guides and destinations | 120 | 120 a minute |
| Quotes | 30 | 30 a minute |
| Bookings | 10 | 5 a minute |
| Checking a booking | 20 | 20 a minute |
| Portal sign-in codes | 5 | 1 a minute |
| Portal code checks | 10 | 2 a minute |
For real messages and what to do about them, see Troubleshooting.
Was this page helpful?