Fortrip

API quickstart

Book a stay over HTTP in five steps, then each call in curl, TypeScript and Python. Every example is a file checked against the contract.

Every call sends FORTRIP_TOKEN, an access token of the user who signs in. There is no test environment: searching, getting offers and creating an order are free, an unpaid order expires on its own, and any payment is real. See Testing your integration.

The TypeScript examples use fetch on Node.js, and the Python examples use httpx.

End to end in five steps

You need curl and jq. Each call saves its answer to a file, and the next call takes its id from that file.

1. Sign in

Run this, then approve the sign-in in your browser. It saves the sign-in script as device-login.sh and runs it, keeping the access token and the refresh token in token.json. Sign in explains the flow.

umask 077 # token.json and the files after it are readable by you only
cat > device-login.sh <<'SH'
# Sign in with the OAuth device flow and print the tokens.
# Needs curl and jq. Prints access_token and refresh_token as JSON: export the
# access token as FORTRIP_TOKEN, and keep the refresh token to get a new one.
set -euo pipefail

config=$(curl -sS --fail-with-body --max-time 60 "https://api.fortrip.ai/v1/auth/config")
client_id=$(jq -r .clientId <<<"$config")
token_endpoint=$(jq -r .tokenEndpoint <<<"$config")

device=$(curl -sS --fail-with-body --max-time 60 "$(jq -r .deviceAuthorizationEndpoint <<<"$config")" \
  -d client_id="$client_id" \
  --data-urlencode scope="$(jq -r .scopes <<<"$config")")
device_code=$(jq -r '.device_code // empty' <<<"$device")
if [ -z "$device_code" ]; then
  echo "No device_code in the answer: $device" >&2
  exit 1
fi
echo "Open $(jq -r '.verification_uri_complete // .verification_uri' <<<"$device") and approve the sign-in." >&2

interval=$(jq -r '.interval // 5' <<<"$device")
deadline=$((SECONDS + $(jq -r '.expires_in // 600' <<<"$device")))

while [ "$SECONDS" -lt "$deadline" ]; do
  sleep "$interval"
  token=$(curl -sS --max-time 60 "$token_endpoint" \
    -d grant_type=urn:ietf:params:oauth:grant-type:device_code \
    -d device_code="$device_code" \
    -d client_id="$client_id")
  case "$(jq -r '.error // empty' <<<"$token")" in
    "") jq '{access_token, refresh_token}' <<<"$token"; exit 0 ;;
    authorization_pending) ;;
    slow_down) interval=$((interval + 5)) ;;
    *) echo "$token" >&2; exit 1 ;;
  esac
done
echo "The sign-in expired before it was approved. Run this again." >&2
exit 1
SH
bash device-login.sh > token.json
export FORTRIP_TOKEN=$(jq -r .access_token token.json)

Search a city for the stay, and take the first hotel's hotelId.

curl -sS --fail-with-body --max-time 60 -X POST "https://api.fortrip.ai/v1/hotels/search" \
  -H "Authorization: Bearer $FORTRIP_TOKEN" \
  -H "Content-Type: application/json" \
  --data @- > search.json <<JSON
{
  "destination": {
    "kind": "place_name",
    "name": "Kyoto",
    "countryCode": "JP"
  },
  "stay": {
    "checkIn": "2027-03-10",
    "checkOut": "2027-03-12",
    "rooms": [
      {
        "adults": 2,
        "childrenAges": []
      }
    ]
  },
  "filters": {
    "amenities": [
      "wifi"
    ]
  },
  "sort": "price_asc"
}
JSON
hotel=$(jq -re '.data[0].hotelId' search.json) || { echo "No hotel found: search another place or other dates." >&2; false; }

The answer, abridged:

{
  "destination": {
    "place": {
      "placeId": "plc_1857910",
      "name": "Kyoto"
    }
  },
  "data": [
    {
      "hotelId": "htl_Qm3xK9vT2pLw7Hn4Rb8Z",
      "name": "Kamo Lantern Hotel",
      "price": {
        "fromPerRoomStay": {
          "amount": "123.00",
          "currency": "USD"
        }
      }
    }
  ],
  "nextCursor": "c_kXy7Qp2LmN4"
}

3. Get offers

Get live offers for that hotel, and take the first offer's offerId.

curl -sS --fail-with-body --max-time 60 -X POST "https://api.fortrip.ai/v1/hotels/offers" \
  -H "Authorization: Bearer $FORTRIP_TOKEN" \
  -H "Content-Type: application/json" \
  --data @- > offers.json <<JSON
{
  "hotelIds": [
    "${hotel:?no hotelId: run step 2 first}"
  ],
  "stay": {
    "checkIn": "2027-03-10",
    "checkOut": "2027-03-12",
    "rooms": [
      {
        "adults": 2,
        "childrenAges": []
      }
    ]
  },
  "filters": {
    "requireFreeCancellation": true
  }
}
JSON
offer=$(jq -re '[.hotels[].offers[]][0].offerId' offers.json) || { echo "No offer matches: pick another hotel from step 2." >&2; false; }

The answer, abridged:

{
  "hotels": [
    {
      "hotel": {
        "hotelId": "htl_Qm3xK9vT2pLw7Hn4Rb8Z"
      },
      "offers": [
        {
          "offerId": "off_Bz6yN1cD5fGh8JkM2pQs",
          "expiresAt": "2027-02-20T08:30:00Z",
          "price": {
            "total": {
              "amount": "132.40",
              "currency": "USD"
            }
          },
          "cancellation": {
            "status": "free",
            "freeCancellationEndsAt": "2027-03-08T15:00:00Z"
          },
          "confirmation": {
            "mode": "instant"
          }
        }
      ]
    }
  ]
}

4. Create the order

Order the offer with the guests and a contact. Nothing is charged. It can take a couple of minutes, so allow at least three.

curl -sS --fail-with-body --max-time 180 -X POST "https://api.fortrip.ai/v1/orders" \
  -H "Authorization: Bearer $FORTRIP_TOKEN" \
  -H "Content-Type: application/json" \
  --data @- > order.json <<JSON
{
  "contact": {
    "firstName": "Ada",
    "lastName": "Lovelace",
    "email": "ada@example.com",
    "phone": "+14155550132"
  },
  "items": [
    {
      "offerId": "${offer:?no offerId: run step 3 first}",
      "hotel": {
        "rooms": [
          {
            "guests": [
              {
                "firstName": "Ada",
                "lastName": "Lovelace"
              },
              {
                "firstName": "Charles",
                "lastName": "Babbage"
              }
            ]
          }
        ]
      }
    }
  ]
}
JSON

The answer, abridged:

{
  "orderId": "ord_Wr4tY7uI0oPa3SdF6gHj",
  "reference": "FT-8KQ2MX",
  "status": "awaiting_payment",
  "price": {
    "total": {
      "amount": "132.40",
      "currency": "USD"
    }
  },
  "payment": {
    "url": "https://fortrip.ai/pay/ord_Wr4tY7uI0oPa3SdF6gHj?s=cs_live_b1Q9xT4mK7pW2rZ8vN3c",
    "expiresAt": "2027-02-20T11:49:12Z"
  }
}

If the price or terms changed, the answer is 409 offer_changed instead, and order.json holds the error. Show the user error.details.changes; if they agree, order error.details.offer.offerId the same way. See When the offer changed.

5. Give the user the payment link

Show the user the total and every term, and wait for a clear yes. Then give them payment.url, where they pay.

jq -r .payment.url order.json
https://fortrip.ai/pay/ord_Wr4tY7uI0oPa3SdF6gHj?s=cs_live_b1Q9xT4mK7pW2rZ8vN3c

Sign in

This script runs the OAuth device flow with the settings GET /v1/auth/config returns, and prints the access token and the refresh token. Authentication explains the flow and when to use another.

#!/usr/bin/env bash
# Sign in with the OAuth device flow and print the tokens.
# Needs curl and jq. Prints access_token and refresh_token as JSON: export the
# access token as FORTRIP_TOKEN, and keep the refresh token to get a new one.
set -euo pipefail

config=$(curl -sS --fail-with-body --max-time 60 "https://api.fortrip.ai/v1/auth/config")
client_id=$(jq -r .clientId <<<"$config")
token_endpoint=$(jq -r .tokenEndpoint <<<"$config")

device=$(curl -sS --fail-with-body --max-time 60 "$(jq -r .deviceAuthorizationEndpoint <<<"$config")" \
  -d client_id="$client_id" \
  --data-urlencode scope="$(jq -r .scopes <<<"$config")")
device_code=$(jq -r '.device_code // empty' <<<"$device")
if [ -z "$device_code" ]; then
  echo "No device_code in the answer: $device" >&2
  exit 1
fi
echo "Open $(jq -r '.verification_uri_complete // .verification_uri' <<<"$device") and approve the sign-in." >&2

interval=$(jq -r '.interval // 5' <<<"$device")
deadline=$((SECONDS + $(jq -r '.expires_in // 600' <<<"$device")))

while [ "$SECONDS" -lt "$deadline" ]; do
  sleep "$interval"
  token=$(curl -sS --max-time 60 "$token_endpoint" \
    -d grant_type=urn:ietf:params:oauth:grant-type:device_code \
    -d device_code="$device_code" \
    -d client_id="$client_id")
  case "$(jq -r '.error // empty' <<<"$token")" in
    "") jq '{access_token, refresh_token}' <<<"$token"; exit 0 ;;
    authorization_pending) ;;
    slow_down) interval=$((interval + 5)) ;;
    *) echo "$token" >&2; exit 1 ;;
  esac
done
echo "The sign-in expired before it was approved. Run this again." >&2
exit 1

GET /v1/auth/config answers, abridged. The values are examples: read them from it each time, as the script does.

{
  "clientId": "ExampleClientId0",
  "scopes": "openid profile email offline_access",
  "deviceAuthorizationEndpoint": "https://clerk.fortrip.ai/oauth/device_authorization",
  "tokenEndpoint": "https://clerk.fortrip.ai/oauth/token"
}

Refresh a token

The access token is short-lived. Get a new one with the refresh token, at the same token endpoint. Each refresh returns a new refresh token; keep only the newest (why).

umask 077 # the new token.json is readable by you only
config=$(curl -sS --fail-with-body "https://api.fortrip.ai/v1/auth/config")
refresh_token=$(jq -r .refresh_token token.json)
curl -sS --fail-with-body "$(jq -r .tokenEndpoint <<<"$config")" \
  -d grant_type=refresh_token \
  -d refresh_token="$refresh_token" \
  -d client_id="$(jq -r .clientId <<<"$config")" > token.new.json && mv token.new.json token.json
export FORTRIP_TOKEN=$(jq -r .access_token token.json)
#!/usr/bin/env bash
# Search hotels in a city.
# Needs FORTRIP_TOKEN, a Fortrip access token (see Sign in above).
set -euo pipefail

curl -sS --fail-with-body --max-time 60 -X POST "https://api.fortrip.ai/v1/hotels/search" \
  -H "Authorization: Bearer $FORTRIP_TOKEN" \
  -H "Content-Type: application/json" \
  --data @- <<'JSON'
{
  "destination": {
    "kind": "place_name",
    "name": "Kyoto",
    "countryCode": "JP"
  },
  "stay": {
    "checkIn": "2027-03-10",
    "checkOut": "2027-03-12",
    "rooms": [
      {
        "adults": 2,
        "childrenAges": []
      }
    ]
  },
  "filters": {
    "amenities": [
      "wifi"
    ]
  },
  "sort": "price_asc"
}
JSON

Its answer is under step 2.

Offers

#!/usr/bin/env bash
# Get live offers for a hotel.
# Needs FORTRIP_TOKEN, a Fortrip access token (see Sign in above).
set -euo pipefail

curl -sS --fail-with-body --max-time 60 -X POST "https://api.fortrip.ai/v1/hotels/offers" \
  -H "Authorization: Bearer $FORTRIP_TOKEN" \
  -H "Content-Type: application/json" \
  --data @- <<'JSON'
{
  "hotelIds": [
    "htl_Qm3xK9vT2pLw7Hn4Rb8Z"
  ],
  "stay": {
    "checkIn": "2027-03-10",
    "checkOut": "2027-03-12",
    "rooms": [
      {
        "adults": 2,
        "childrenAges": []
      }
    ]
  },
  "filters": {
    "requireFreeCancellation": true
  }
}
JSON

Its answer is under step 3.

Order

Ordering charges nothing. Show the user the total and every term, and wait for a clear yes before anyone pays. The order can take a couple of minutes; a call that timed out is safe to send again. See Orders and payment.

#!/usr/bin/env bash
# Create an order from an offer. Nothing is charged until it is paid.
# It can take a couple of minutes. A call that timed out is safe to send again:
# https://docs.fortrip.ai/api#retries
# Needs FORTRIP_TOKEN, a Fortrip access token (see Sign in above).
set -euo pipefail

curl -sS --fail-with-body --max-time 180 -X POST "https://api.fortrip.ai/v1/orders" \
  -H "Authorization: Bearer $FORTRIP_TOKEN" \
  -H "Content-Type: application/json" \
  --data @- <<'JSON'
{
  "contact": {
    "firstName": "Ada",
    "lastName": "Lovelace",
    "email": "ada@example.com",
    "phone": "+14155550132"
  },
  "items": [
    {
      "offerId": "off_Bz6yN1cD5fGh8JkM2pQs",
      "hotel": {
        "rooms": [
          {
            "guests": [
              {
                "firstName": "Ada",
                "lastName": "Lovelace"
              },
              {
                "firstName": "Charles",
                "lastName": "Babbage"
              }
            ]
          }
        ]
      }
    }
  ]
}
JSON

Its answer is under step 4.

Pay

An order is paid one way only. Orders and payment says which ways an order offers and when each applies.

The payment link needs no request: give the user the order's payment.url. Read it from the order.

#!/usr/bin/env bash
# Read an order, and print its payment link when it awaits payment.
# Needs FORTRIP_TOKEN, a Fortrip access token (see Sign in above).
set -euo pipefail

link=$(curl -sS --fail-with-body --max-time 60 "https://api.fortrip.ai/v1/orders/ord_Wr4tY7uI0oPa3SdF6gHj" \
  -H "Authorization: Bearer $FORTRIP_TOKEN" \
  | jq -r '.payment.url // empty')
if [ -z "$link" ]; then
  echo "The order has no payment link: it is not awaiting payment, or read it again." >&2
  exit 1
fi
echo "$link"

The answer, abridged:

{
  "orderId": "ord_Wr4tY7uI0oPa3SdF6gHj",
  "status": "awaiting_payment",
  "payment": {
    "status": "unpaid",
    "url": "https://fortrip.ai/pay/ord_Wr4tY7uI0oPa3SdF6gHj?s=cs_live_b1Q9xT4mK7pW2rZ8vN3c"
  }
}

Link token

Only for agents that can request a Link token, a shared payment token from the user's Stripe Link wallet.

#!/usr/bin/env bash
# Pay an order with a Link token.
# Needs FORTRIP_TOKEN, a Fortrip access token (see Sign in above).
set -euo pipefail

curl -sS --fail-with-body --max-time 180 -X POST "https://api.fortrip.ai/v1/orders/ord_Wr4tY7uI0oPa3SdF6gHj/pay" \
  -H "Authorization: Bearer $FORTRIP_TOKEN" \
  -H "Content-Type: application/json" \
  --data @- <<'JSON'
{
  "sharedPaymentToken": "spt_ExampleTokenFromTheUsersLinkWallet",
  "total": {
    "amount": "132.40",
    "currency": "USD"
  }
}
JSON

The answer, abridged:

{
  "orderId": "ord_Wr4tY7uI0oPa3SdF6gHj",
  "status": "confirming",
  "payment": {
    "status": "authorized",
    "url": null
  }
}

Alipay or WeChat Pay

#!/usr/bin/env bash
# Pay an order with Alipay or WeChat Pay: opens the payment.
# Needs FORTRIP_TOKEN, a Fortrip access token (see Sign in above).
set -euo pipefail

curl -sS --fail-with-body --max-time 180 -X POST "https://api.fortrip.ai/v1/orders/ord_Wr4tY7uI0oPa3SdF6gHj/pay" \
  -H "Authorization: Bearer $FORTRIP_TOKEN" \
  -H "Content-Type: application/json" \
  --data @- <<'JSON'
{
  "method": "alipay_wechat_pay",
  "total": {
    "amount": "132.40",
    "currency": "USD"
  }
}
JSON

The answer, abridged:

{
  "orderId": "ord_Wr4tY7uI0oPa3SdF6gHj",
  "status": "awaiting_payment",
  "payment": {
    "wallets": {
      "open": true,
      "current": "wechat_pay",
      "wechatPay": {
        "qrData": "weixin://wxpay/bizpayurl?pr=Q7nT2kX",
        "qrImageUrl": "https://qr.stripe.com/YWNjdF9FeGFtcGxlV2VDaGF0UGF5UXI.png",
        "url": "https://fortrip.ai/pay/ord_Wr4tY7uI0oPa3SdF6gHj/wechat?t=wl_4mZ8qR2xN7cV1bK6"
      },
      "alipay": {
        "url": "https://fortrip.ai/pay/ord_Wr4tY7uI0oPa3SdF6gHj/alipay?t=wl_4mZ8qR2xN7cV1bK6"
      }
    }
  }
}

List orders

#!/usr/bin/env bash
# List your orders, newest first.
# Needs FORTRIP_TOKEN, a Fortrip access token (see Sign in above).
set -euo pipefail

curl -sS --fail-with-body --max-time 60 "https://api.fortrip.ai/v1/orders?limit=10" \
  -H "Authorization: Bearer $FORTRIP_TOKEN"

The answer, abridged:

{
  "data": [
    {
      "orderId": "ord_Wr4tY7uI0oPa3SdF6gHj",
      "reference": "FT-8KQ2MX",
      "status": "awaiting_payment"
    }
  ],
  "nextCursor": null
}

Cancel

Cancel only while the order's cancel.available is true, with maximumPenalty the number in cancel.penalty.amount that the user agreed to.

#!/usr/bin/env bash
# Cancel an order under its terms, accepting no penalty.
# Needs FORTRIP_TOKEN, a Fortrip access token (see Sign in above).
set -euo pipefail

curl -sS --fail-with-body --max-time 60 -X POST "https://api.fortrip.ai/v1/orders/ord_Wr4tY7uI0oPa3SdF6gHj/cancel" \
  -H "Authorization: Bearer $FORTRIP_TOKEN" \
  -H "Content-Type: application/json" \
  --data @- <<'JSON'
{
  "maximumPenalty": 0
}
JSON

The answer, abridged:

{
  "orderId": "ord_Wr4tY7uI0oPa3SdF6gHj",
  "status": "cancelling",
  "cancel": {
    "available": false,
    "reason": "in_progress",
    "penalty": null,
    "refund": null
  }
}

On this page