Fortrip

HTTP API

Fortrip API v1 is the HTTP API behind the MCP tools and the CLI. Each endpoint takes the same input as its MCP tool and answers the same object.

Base URL

https://api.fortrip.ai/v1

JSON over HTTPS. Reads are GET with query parameters; everything else is POST with a JSON body. A request with a field the input doesn't define is refused with invalid_request. There is no CORS: call the API from a server, a script or the CLI, not from a web page.

Authentication

Every endpoint except GET /v1/auth/config needs a Fortrip OAuth access token, sent as Authorization: Bearer <token>. Cookies and API keys are not accepted. The tokens are the ones the CLI and MCP clients get when a user signs in to Fortrip; GET /v1/auth/config returns the OAuth settings a client signs in with (the CLI uses the device authorization flow).

To get a token for your own integration:

  1. GET /v1/auth/config returns issuer, clientId, scopes, deviceAuthorizationEndpoint, tokenEndpoint and revocationEndpoint.
  2. Run the OAuth device authorization flow (RFC 8628) with them: post client_id and scope as a form to deviceAuthorizationEndpoint, show the user its verification_uri_complete (or verification_uri and user_code), then poll tokenEndpoint every interval seconds with grant_type=urn:ietf:params:oauth:grant-type:device_code, the device_code and client_id until the user approves.
  3. Request offline_access, which scopes includes, to get a refresh token with the access token.
  4. The access token is short-lived: its lifetime, in seconds, is the token response's expires_in. Refresh it at tokenEndpoint with grant_type=refresh_token and your client_id. Every refresh returns a new refresh token: keep only the newest, because reusing an old one revokes the whole grant and the user has to sign in again. To sign out, revoke the refresh token at revocationEndpoint, again with your client_id.

A missing or rejected token answers 401 unauthenticated. GET /v1/me returns the signed-in account. Its email is null unless the token has the email scope, and its name unless it has profile.

Endpoints

EndpointInputAnswerMCP / CLI
POST /v1/hotels/search
Search hotels
JSON bodySearch resultsearch_hotels
CLI
GET /v1/hotels/{hotelId}
Hotel details
Path and queryHotelget_hotel
CLI
POST /v1/hotels/offers
Live offers
JSON bodyOffers resultget_hotel_offers
CLI
POST /v1/orders
Create order
JSON bodyOrderbook_hotel
CLI
GET /v1/orders/{orderId}
Get order
Path and queryOrderget_order
CLI
GET /v1/orders
List orders
Path and queryOrder listlist_orders
CLI
POST /v1/orders/{orderId}/cancel
Cancel order
JSON bodyOrder (cancel)cancel_order
CLI
POST /v1/orders/{orderId}/pay
Pay order
JSON bodyOrder (pay)pay_order
CLI
GET /v1/me
Signed-in account
NoneAccountCLI
GET /v1/auth/config
CLI sign-in settings
NoneOAuth settingsCLI

The API reference has each endpoint's parameters, body and answers, generated from the contract, as is the OpenAPI 3.1 document.

A path parameter is also a field of the MCP tool's input: GET /v1/hotels/{hotelId}?include=rooms&locale=en takes what get_hotel takes as { hotelId, include: "rooms", locale: "en" }. Successful answers are the object itself, with no envelope; failures are the error envelope.

Conventions

  • Fields are camelCase. Ids are opaque strings with a prefix: htl_ hotels, off_ offers, ord_ orders and plc_ places. Pass them back exactly as received.
  • Instants end in At and are RFC 3339 in UTC. Dates are yyyy-MM-dd in the hotel's calendar, and times of day HH:mm in the hotel's time zone, its IANA timeZone.
  • Money is { amount: "132.40", currency: "USD" }, the amount a decimal string. Money in a request must be USD. An approx amount is an estimate in the requested displayCurrency, with the date of its rate; it never changes what is charged.
  • Coordinates are { lat, lng } in WGS84; distances are in meters.
  • Names with an English and a Chinese form are name, in the requested locale, plus names: { en, zh }. Other text from the catalogue is { text, locale }, with locale null when unknown. Messages the API writes are in English.
  • Unknown is not no. A list or flag the source didn't state is null, or comes with known, listed, disclosure or status saying so. An amenity that isn't listed may still exist.

Headers

HeaderMeaning
Request-IdOn every response, and as requestId in an error. Quote it to support.
RateLimit-Limit, RateLimit-Remaining, RateLimit-ResetOn every response a rate limit counted: the limit per minute, what is left, and seconds until the window resets. See rate limits.
Retry-AfterOn every retryable error: seconds to wait. The envelope's retryAfterSeconds says the same.
Idempotent-Replayedtrue when POST /v1/orders answers an order it already created for the same details.

Pagination

Search results and the order list are pages: { data, nextCursor } for orders, and data with nextCursor alongside the rest of a search result. Pass nextCursor back as cursor; a next page of a search takes only cursor and, optionally, limit. nextCursor is null on the last page. A cursor belongs to its request and user; an expired, edited or foreign one answers 400 cursor_invalid.

Live offers are not a page: they are grouped by hotel, under hotels, with the hotels that have nothing to offer under unavailable and the reason.

Orders and payment

  • POST /v1/orders checks the offer with the hotel supplier once. If nothing changed, it answers 201 with an order awaiting_payment, its price.total in USD and the ways it can be paid. Stripe's page may let the user pay in their card's local currency at Stripe's rate.
  • If the price or any term changed, it answers 409 offer_changed with every change and a new offer. No order is made at a price the user hasn't seen.
  • Sending the same details for the same offer again answers the same order with 200 and Idempotent-Replayed: true. Different details for an ordered offer answer 409 offer_already_ordered.
  • Before the user pays, show them the total and every term and wait for a clear yes. The MCP tools carry this rule with every answer that holds a payment.url, as nextStep:

Before you give the user an order's payment.url, show them its total and currency, the stay (hotel, room, dates, rooms and guests per room) and every term (meals, cancellation, fees payable at the property), then end your reply by asking them to confirm, and wait for a clear yes. Only after they say yes, give them payment.url exactly as returned.

An order is paid one of the ways below. Pay one way only: once the order is paid another way, payment.url stops working; a payment that fails leaves it open.

Every order awaiting payment has one: payment.url.

Charged: The user pays on Stripe Checkout, reached through a Fortrip page. A card is held until the hotel confirms, then charged; a method that can't be held is charged at once and refunded in full if the hotel can't confirm.

Show the user the total and every term, wait for a clear yes, then give them payment.url exactly as returned. Anyone holding it can pay, except when requiresSignIn is true (an order started on the Fortrip website): then only the signed-in owner can, on the Fortrip booking page. It stays open until the order is paid, whichever way, or until payment.expiresAt.

Link shared payment token

When payment.handlers isn't empty and your agent can request a Stripe shared payment token from the user's Link wallet (US and Canadian Link accounts). Never ask the user to type or paste a token.

Charged: The amount is held until the hotel confirms, then charged; if the hotel can't confirm, the hold is released.

Request the token from the user's Link wallet with the handler's config.business_profile and the order's payment.credentialLimits, then call POST /v1/orders/{orderId}/pay (MCP pay_order, CLI fortrip orders pay --token) with sharedPaymentToken, or payment as UCP's Complete Checkout sends it (payment.instruments, whose selected instrument is a stripe_payments card with a stripe_payment_token credential), and total, the order's price.total. A token pays one order only. payment.handlers is empty, and payment.credentialLimits null, unless the order is charged in USD, at most $500, paid on a page that needs no sign-in (requiresSignIn: false), without a negotiated price, with at least 15 minutes left to pay, fewer than 5 tokens tried, no token payment under way and no Alipay or WeChat Pay payment opened.

Alipay or WeChat Pay

When payment.wallets isn't null: the order is charged in USD, paid on a page that needs no sign-in, without a negotiated price, with at least 15 minutes left to pay and no token payment under way.

Charged: Charged at once when the user pays; if the hotel can't confirm, the payment is refunded in full.

Call POST /v1/orders/{orderId}/pay (MCP pay_order, CLI fortrip orders pay --wallet) with method set to "alipay_wechat_pay" and total, the order's price.total. The answer is the order, whose payment.wallets holds a WeChat Pay code (wechatPay.qrData to draw, or wechatPay.qrImageUrl, a PNG) and a link for each wallet. Show the user the code and both links; they pay once, by one of them. A WeChat Pay code lasts 120 minutes; after that the WeChat Pay link opens a new one, or call again. Calling again while the payment is open answers the same payment. Then read the order until it is paid.

POST /v1/orders/{orderId}/pay takes total, the order's price.total exactly as returned, and exactly one of sharedPaymentToken, payment and method, as its way above says. A total other than the order's answers payment_total_changed and charges nothing. The answer is the order; read it again with GET /v1/orders/{orderId} to follow its status and payment.status. A payment that can't go ahead answers order_not_payable or one of the payment_ error codes.

Orders can't be changed through the API. POST /v1/orders/{orderId}/cancel cancels one under its terms while cancel.available is true, with maximumPenalty a number equal to cancel.penalty.amount. GET /v1/orders lists the user's hotel orders, newest first, including those made on the Fortrip website.

Changes within v1

Fortrip's agent API is pre-release. Until general availability, breaking changes may happen. A breaking change ships only in a new major version of the contract, @fortrip/agent-contract, and is announced in the changelog with migration notes. After general availability, v1 will change only additively: new fields, endpoints, enum values and error codes.

Read responses tolerantly either way: allow fields you don't know, treat an unknown enum value or error code as its raw string (or by its HTTP status and retryable), and don't fail on them.

On this page