Fortrip

Errors & limits

The HTTP API, the MCP tools and the CLI share one error envelope, one set of codes and the same limits.

Error format

Every failure answers the same envelope:

{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests; retry later.",
    "retryable": true,
    "retryAfterSeconds": 42,
    "details": { "scope": "search" },
    "requestId": "req_…"
  }
}
  • code is one of the codes below; act on it, not on message, which is English for people. A code you don't know: go by the HTTP status and retryable.
  • param, when present, points at the field at fault: a JSON Pointer into the body (/stay/checkIn), or /query/<name> and /path/<name>.
  • A retryable error carries retryAfterSeconds and the Retry-After header.
  • A wrong method answers invalid_request with HTTP 405 and an Allow header.
  • Over MCP an error is isError: true: content[0] says what happened and what to do next, content[1] is the envelope as JSON text, and _meta["ai.fortrip/error"] carries it too. The CLI prints the error to stderr (the envelope with --json) and exits with a code below.

Error codes

CodeStatusRetryMeaning and details
invalid_request400NoThe request doesn't match the input schema, or breaks a rule between fields (a price sort without a stay, say). issues lists each problem: pointer, a JSON Pointer to the field, and message. Filtering by an amenity that can't filter adds filterable, the codes that can, and share: for the requested code, listedShare is the fraction, from 0 to 1, of the hotels that list facilities that list it.
details: { issues: { pointer: string; message: string }[]; filterable?: ("parking" | "wifi" | "laundry" | "restaurant" | "room_service" | "bar" | "non_smoking" | "business_center" | "pool" | "airport_shuttle" | "spa" | "gym" | "accessible" | "ev_charging" | "air_conditioning" | "pets_allowed" | "kitchen" | "other")[]; share?: { code: string; listedShare: number } }
cursor_invalid400NoThe cursor expired, belongs to another request or user, or was never a cursor. Start again from the first page.
details: { reason: "expired" | "mismatch" | "malformed" }
unauthenticated401NoNo access token, or one Fortrip doesn't accept. Sign in again.
forbidden403NoThe token is valid but doesn't allow this call.
not_found404NoNo such hotel or order, or the order belongs to another account.
order_removed410NoThe order was never completed and has been removed; no payment is recorded as held. Ordering the same stay again brings back the same orderId.
details: { orderId: string; reference: string }
offer_expired409NoThe offer's expiresAt has passed. Get offers again and order a new one.
details: { offerId: string; expiredAt: string }
offer_changed409NoThe hotel supplier's price or terms changed since the offer. changes lists every change, and offer is a new offer with the new terms. Show the changes, ask the user again, then order the new offer.
details: { changes: object[]; offer: object }
offer_already_ordered409NoThe offer was ordered with different details. Read the order in orderId, or get a new offer.
details: { orderId: string }
order_exists409NoThis stay already has an open order for one of these guests (orderId, status): show it instead of ordering again; its payment link stays valid. A party with none of its guests can order the stay from a new offer (get offers again), and so can the user once the stay's order is confirmed, cancelled or refused by the hotel. It also answers when the stay's earlier order failed at the hotel with its outcome unknown, or the stay has had 20 orders; for those, the user emails info@fortrip.ai.
details: { orderId: string; status: "awaiting_payment" | "expired" | "confirming" | "confirmed" | "failed" | "cancelling" | "cancelled" | "needs_attention" }
order_in_progress409YesThe same offer is being ordered by another request right now, or (for cancel_order) the order is still being confirmed. Retry after retryAfterSeconds to get its answer.
details: { orderId: string }
order_attempts_exhausted409NoThis order has reached its limit of attempts and can't be submitted again. The user can email info@fortrip.ai with its reference.
details: { orderId: string }
cancellation_changed409NoThe order's cancellation penalty is now higher than the maximumPenalty sent, usually because a penalty step has passed. Read the order again, show the user its cancel.penalty and cancel.refund, and ask again before cancelling.
order_not_cancellable409NoThe order can't be cancelled now; reason says why, as order.cancel.reason does. stay_started, non_refundable and refused won't change: the user emails support. in_progress and not_confirmed mean the order isn't confirmed yet.
details: { reason: "cancellable" | "already_cancelled" | "in_progress" | "not_confirmed" | "stay_started" | "non_refundable" | "refused" | "terms_unknown" | "needs_attention" }
order_not_payable409Nopay_order on an order that isn't awaiting payment, or whose payment window closes within 2 minutes. status is the order's. This call charged nothing.
details: { orderId: string; status: "awaiting_payment" | "expired" | "confirming" | "confirmed" | "failed" | "cancelling" | "cancelled" | "needs_attention" }
payment_in_progress409YesAnother payment of the order is under way. Read the order after retryAfterSeconds.
details: { orderId: string }
payment_total_changed409NoThe total sent isn't the order's; details.total is. Confirm that total with the user, then call again with it: with the same token, or with method again for Alipay or WeChat Pay. This call charged nothing.
details: { orderId: string; total: { amount: string; currency: string } }
payment_credential_invalid422NoThe shared payment token can't pay this order. reason: not_found, currency or amount (its limits don't fit the order's payment.credentialLimits), expired, deactivated, used (it paid or tried an order before: a token pays one order only) or other_seller. Request a new token.
details: { reason: "not_found" | "currency" | "amount" | "expired" | "deactivated" | "used" | "other_seller" }
payment_declined402NoThe card was declined; declineCode is the bank's reason when it gives one. The user can pick another payment method in their wallet, or pay by payment.url, which stays open.
details: { declineCode: string | null }
payment_action_required402NoThe card needs a verification (such as 3-D Secure) that a token payment can't give. The user pays by payment.url, which stays open.
payment_method_unavailable403NoThe order can't be paid the way asked. With a token: its payment.handlers is empty, it took its limit of tokens, Stripe refused the token for a reason a new one wouldn't fix, or an Alipay or WeChat Pay payment was opened on it (use payment.wallets). With method: Alipay and WeChat Pay aren't offered for it (payment.wallets is null), or too little of its payment window is left to start a payment. Otherwise give the user payment.url.
destination_ambiguous422NoSeveral places carry the name. candidates lists up to 8 with their hotel counts, candidatesTotal how many there are, and nameAsText how many hotels match the name as text. Ask the user which one is meant, then search it by placeId.
details: { name: string; countryCode: string; candidates: object[]; candidatesTotal: number; nameAsText: number | null }
destination_not_found422Nono_match: no place in the country carries the name. unknown_id: the placeId isn't known. not_a_locality: the place is no locality hub; hub is the hub it belongs to, and searching the town itself with place gives a radius around it.
details: { reason: "no_match"; name: string; countryCode: string; nameAsText: number | null } or { reason: "unknown_id"; placeId: string } or { reason: "not_a_locality"; placeId: string; hub: object | null; hubTotal: number | null }
search_too_broad422NoA price sort or price filter over an area or text with more matches than can be priced (poolLimit). Search one of the listed localities as { kind: "locality", placeId }.
details: { matched: number; poolLimit: number; localities: object; center: object | null }
stay_unsupported422NoOffers and orders take one party per call, with every room the same (mixed_occupancy), and stays up to max nights (max_nights).
details: { reason: "mixed_occupancy" } or { reason: "max_nights"; max: number }
stay_in_past422NoCheck-in is already past in the hotel's time zone. Ask the user for new dates.
details: { checkIn: string; timeZone: string | null }
offer_unavailable422NoThe hotel supplier no longer sells the offer (sold_out), or refused it (refused). Get offers again.
details: { offerId: string; reason: "sold_out" | "refused" }
rate_limited429YesToo many calls of one kind (scope) in a minute. Retry after retryAfterSeconds.
details: { scope: "search" | "offers" | "hotel" | "orders_read" | "orders_create" | "orders_cancel" | "orders_pay" }
ordering_paused503YesOrdering is paused for now. Search and offers still work, and an order already made can still be read. Retry after retryAfterSeconds.
service_unavailable503YesA service Fortrip depends on is briefly unavailable. Retry after retryAfterSeconds.
supplier_unavailable503 / 504YesThe hotel supplier didn't answer. not_started (503): nothing was sent, so retrying is safe. unknown (504): the call was sent and timed out; for an order, read your orders before ordering again.
details: { outcome: "not_started" | "unknown" }
internal500NoAn unexpected error. The requestId helps support find it.

CLI exit codes

CodeMeaning
0Success.
1Any other error. An error code the CLI doesn't know exits by its HTTP status: 400 → 2, 401 → 3, 429 → 4, any other status 1.
2Invalid arguments, found by the CLI or answered as invalid_request.
3Not signed in, or the saved sign-in is no longer accepted (unauthenticated). stderr normally includes loginUrl and userCode: show them to the user and rerun the command once they have approved. When FORTRIP_TOKEN is set and Fortrip rejects it, stderr has tokenSource: "FORTRIP_TOKEN" and no link: replace or unset the token.
4Rate limited (rate_limited). stderr includes retryAfterSeconds.

Rate limits

Per account and per minute, shared by the HTTP API, the MCP tools and the CLI. Over a limit, the answer is 429 rate_limited with the limit's scope.

ScopeCallsPer minute
searchSearch10
offersOffers20
hotelHotel details30
orders_createOrder creation20
orders_readOrder reads, order lists60
orders_cancelOrder cancellations10
orders_payOrder payments10

If the rate limiter itself is down, calls go through, except order creation, order cancellations and order payments, which answer 503 service_unavailable instead.

Limits

LimitValueApplies to
Rooms1–8Per search, offers call or order.
Adults per room1–8Per room.
Children per room0–4Per room, each with an age.
Child age0–17Years, at check-in.
Nights per stay15Search, offers and orders answer stay_unsupported for a longer stay (invalid_request past 30); split it into consecutive stays, each its own order.
Hotels per offers call15hotelIds in one offers request.
Offers per hotel1–20, default 10limitPerHotel: a spread of choices (the cheapest, the cheapest free to cancel, the cheapest with breakfast, other rooms), listed cheapest first.
Page size1–50, default 20limit: hotels per search page, orders per list page.
Search text1–128 characterstext in a search.
Search radius1–80,000 m, default 8,000 mradiusMeters. The default applies to towns, neighbourhoods, small islands and points; a whole area has no radius unless one is given.
Amenity filtersup to 9filters.amenities in a search.
Guest name60 charactersEach first or last name, in Latin letters.
Contact first name59 characterscontact.firstName of an order.
Contact last name59 characterscontact.lastName of an order.
Contact phone3–40 characterscontact.phone, with its country code.
Special requests500 charactersIn English; the hotel doesn't guarantee them.
Hotels priced for a price sort or filter3,000Over an area or text with more matches, the search answers search_too_broad with its localities; over a radius, it prices the nearest ones.
Candidates shown8Places listed by destination_ambiguous.
Candidates countedup to 200candidatesTotal of destination_ambiguous.
Localities listed8localities of an area search or search_too_broad, by match count.
Token paymentup to $500An order's total payable with a shared payment token (Link's cap on a spend request); a larger order's payment.handlers is empty.
Time left to offer token and wallet payment15 minutesOnce less is left before the order's payment.expiresAt, payment.handlers is empty and no Alipay or WeChat Pay payment can be opened.
Time left to pay2 minutesOnce less is left before the order's payment.expiresAt, pay_order answers order_not_payable, by token or wallet.
Tokens per order5Shared payment tokens one order can take, declined ones included; past it, payment_method_unavailable.
WeChat Pay code120 minutesHow long a WeChat Pay code in payment.wallets lasts after it is made.
Orders per stay20Orders one account can make for one stay (the same room, rate, dates and party); past it, order_exists.

On this page