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_…"
}
}codeis one of the codes below; act on it, not onmessage, which is English for people. A code you don't know: go by the HTTP status andretryable.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
retryAfterSecondsand theRetry-Afterheader. - A wrong method answers
invalid_requestwith HTTP 405 and anAllowheader. - 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
| Code | Status | Retry | Meaning and details |
|---|---|---|---|
invalid_request | 400 | No | The 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_invalid | 400 | No | The cursor expired, belongs to another request or user, or was never a cursor. Start again from the first page.details: { reason: "expired" | "mismatch" | "malformed" } |
unauthenticated | 401 | No | No access token, or one Fortrip doesn't accept. Sign in again. |
forbidden | 403 | No | The token is valid but doesn't allow this call. |
not_found | 404 | No | No such hotel or order, or the order belongs to another account. |
order_removed | 410 | No | The 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_expired | 409 | No | The offer's expiresAt has passed. Get offers again and order a new one.details: { offerId: string; expiredAt: string } |
offer_changed | 409 | No | The 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_ordered | 409 | No | The offer was ordered with different details. Read the order in orderId, or get a new offer.details: { orderId: string } |
order_exists | 409 | No | This 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_progress | 409 | Yes | The 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_exhausted | 409 | No | This 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_changed | 409 | No | The 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_cancellable | 409 | No | The 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_payable | 409 | No | pay_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_progress | 409 | Yes | Another payment of the order is under way. Read the order after retryAfterSeconds.details: { orderId: string } |
payment_total_changed | 409 | No | The 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_invalid | 422 | No | The 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_declined | 402 | No | The 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_required | 402 | No | The 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_unavailable | 403 | No | The 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_ambiguous | 422 | No | Several 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_found | 422 | No | no_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_broad | 422 | No | A 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_unsupported | 422 | No | Offers 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_past | 422 | No | Check-in is already past in the hotel's time zone. Ask the user for new dates.details: { checkIn: string; timeZone: string | null } |
offer_unavailable | 422 | No | The hotel supplier no longer sells the offer (sold_out), or refused it (refused). Get offers again.details: { offerId: string; reason: "sold_out" | "refused" } |
rate_limited | 429 | Yes | Too 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_paused | 503 | Yes | Ordering is paused for now. Search and offers still work, and an order already made can still be read. Retry after retryAfterSeconds. |
service_unavailable | 503 | Yes | A service Fortrip depends on is briefly unavailable. Retry after retryAfterSeconds. |
supplier_unavailable | 503 / 504 | Yes | The 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" } |
internal | 500 | No | An unexpected error. The requestId helps support find it. |
CLI exit codes
| Code | Meaning |
|---|---|
0 | Success. |
1 | Any 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. |
2 | Invalid arguments, found by the CLI or answered as invalid_request. |
3 | Not 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. |
4 | Rate 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.
| Scope | Calls | Per minute |
|---|---|---|
search | Search | 10 |
offers | Offers | 20 |
hotel | Hotel details | 30 |
orders_create | Order creation | 20 |
orders_read | Order reads, order lists | 60 |
orders_cancel | Order cancellations | 10 |
orders_pay | Order payments | 10 |
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
| Limit | Value | Applies to |
|---|---|---|
| Rooms | 1–8 | Per search, offers call or order. |
| Adults per room | 1–8 | Per room. |
| Children per room | 0–4 | Per room, each with an age. |
| Child age | 0–17 | Years, at check-in. |
| Nights per stay | 15 | Search, 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 call | 15 | hotelIds in one offers request. |
| Offers per hotel | 1–20, default 10 | limitPerHotel: a spread of choices (the cheapest, the cheapest free to cancel, the cheapest with breakfast, other rooms), listed cheapest first. |
| Page size | 1–50, default 20 | limit: hotels per search page, orders per list page. |
| Search text | 1–128 characters | text in a search. |
| Search radius | 1–80,000 m, default 8,000 m | radiusMeters. The default applies to towns, neighbourhoods, small islands and points; a whole area has no radius unless one is given. |
| Amenity filters | up to 9 | filters.amenities in a search. |
| Guest name | 60 characters | Each first or last name, in Latin letters. |
| Contact first name | 59 characters | contact.firstName of an order. |
| Contact last name | 59 characters | contact.lastName of an order. |
| Contact phone | 3–40 characters | contact.phone, with its country code. |
| Special requests | 500 characters | In English; the hotel doesn't guarantee them. |
| Hotels priced for a price sort or filter | 3,000 | Over 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 shown | 8 | Places listed by destination_ambiguous. |
| Candidates counted | up to 200 | candidatesTotal of destination_ambiguous. |
| Localities listed | 8 | localities of an area search or search_too_broad, by match count. |
| Token payment | up to $500 | An 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 payment | 15 minutes | Once 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 pay | 2 minutes | Once less is left before the order's payment.expiresAt, pay_order answers order_not_payable, by token or wallet. |
| Tokens per order | 5 | Shared payment tokens one order can take, declined ones included; past it, payment_method_unavailable. |
| WeChat Pay code | 120 minutes | How long a WeChat Pay code in payment.wallets lasts after it is made. |
| Orders per stay | 20 | Orders one account can make for one stay (the same room, rate, dates and party); past it, order_exists. |