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/v1JSON 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:
GET /v1/auth/configreturnsissuer,clientId,scopes,deviceAuthorizationEndpoint,tokenEndpointandrevocationEndpoint.- Run the OAuth device authorization flow (RFC 8628) with them: post
client_idandscopeas a form todeviceAuthorizationEndpoint, show the user itsverification_uri_complete(orverification_urianduser_code), then polltokenEndpointeveryintervalseconds withgrant_type=urn:ietf:params:oauth:grant-type:device_code, thedevice_codeandclient_iduntil the user approves. - Request
offline_access, whichscopesincludes, to get a refresh token with the access token. - The access token is short-lived: its lifetime, in seconds, is the token response's
expires_in. Refresh it attokenEndpointwithgrant_type=refresh_tokenand yourclient_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 atrevocationEndpoint, again with yourclient_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
| Endpoint | Input | Answer | MCP / CLI |
|---|---|---|---|
POST /v1/hotels/searchSearch hotels | JSON body | Search result | search_hotelsCLI |
GET /v1/hotels/{hotelId}Hotel details | Path and query | Hotel | get_hotelCLI |
POST /v1/hotels/offersLive offers | JSON body | Offers result | get_hotel_offersCLI |
POST /v1/ordersCreate order | JSON body | Order | book_hotelCLI |
GET /v1/orders/{orderId}Get order | Path and query | Order | get_orderCLI |
GET /v1/ordersList orders | Path and query | Order list | list_ordersCLI |
POST /v1/orders/{orderId}/cancelCancel order | JSON body | Order (cancel) | cancel_orderCLI |
POST /v1/orders/{orderId}/payPay order | JSON body | Order (pay) | pay_orderCLI |
GET /v1/meSigned-in account | None | Account | CLI |
GET /v1/auth/configCLI sign-in settings | None | OAuth settings | CLI |
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 andplc_places. Pass them back exactly as received. - Instants end in
Atand are RFC 3339 in UTC. Dates areyyyy-MM-ddin the hotel's calendar, and times of dayHH:mmin the hotel's time zone, its IANAtimeZone. - Money is
{ amount: "132.40", currency: "USD" }, the amount a decimal string. Money in a request must be USD. Anapproxamount is an estimate in the requesteddisplayCurrency, 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 requestedlocale, plusnames: { en, zh }. Other text from the catalogue is{ text, locale }, withlocalenullwhen 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 withknown,listed,disclosureorstatussaying so. An amenity that isn't listed may still exist.
Headers
| Header | Meaning |
|---|---|
Request-Id | On every response, and as requestId in an error. Quote it to support. |
RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset | On every response a rate limit counted: the limit per minute, what is left, and seconds until the window resets. See rate limits. |
Retry-After | On every retryable error: seconds to wait. The envelope's retryAfterSeconds says the same. |
Idempotent-Replayed | true 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/orderschecks the offer with the hotel supplier once. If nothing changed, it answers 201 with an orderawaiting_payment, itsprice.totalin 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_changedwith 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 answer409 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, asnextStep:
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.
Payment link
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.