# 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.

Source: https://docs.bifrostorbit.com/travel/developers/booking-api/

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](https://docs.bifrostorbit.com/travel/developers/api-keys/) 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](https://docs.bifrostorbit.com/travel/everyone/booking-settings/), such as:

```
https://app.bifrostorbit.com/travel/api/public/northwilds
```

Answers 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](https://docs.bifrostorbit.com/travel/developers/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](https://docs.bifrostorbit.com/travel/everyone/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:

```json
{
  "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](https://docs.bifrostorbit.com/travel/developers/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

| 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](https://docs.bifrostorbit.com/travel/developers/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:

```json
{
  "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`:

```json
{ "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](https://docs.bifrostorbit.com/travel/everyone/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`, `capitalAt` and `centre` (`lat`, `lng`) and `mapId` (the numeric ISO code, for world maps)
- `labels`: the tile headings in the chosen language, such as `Hovedstad` for `capital`
- `content`: your own text, as for a continent, or `null` until you publish the country
- `trips`: the published, public trips that visit it, as in the trips list
- `live`: `timeZone`, `weather` (`temperature` in °C, `symbol`, `description`, `icon`, `at`, `credit`) and `rate` (`from` your currency, `to` the country's, `rate`, `date`). `weather` is `null` when there's no forecast, and `rate.rate` is `null` when 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](https://docs.bifrostorbit.com/travel/everyone/destinations/).

## Price a booking

`POST /travel/api/public/northwilds/quote`

```json
{
  "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`

```json
{
  "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:

```json
{
  "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 with `stripe.confirmPayment`. The amount is what is due now, usually the deposit.
- `kind: "setup"`: nothing is due today, so confirm with `stripe.confirmSetup` to 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`

```json
{ "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](https://docs.bifrostorbit.com/travel/everyone/booking-settings/) to your page, and the links in traveller emails go there.

| 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:

```json
{ "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:

```json
{ "error": { "code": "invalid", "message": "Add the passport number for traveller 2" } }
```

| 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](https://docs.bifrostorbit.com/travel/developers/api-keys/) 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](https://docs.bifrostorbit.com/travel/developers/troubleshooting/).
