error.code. Typed payment submissions also return known payment outcomes, including declines, through PaymentAttemptOutcome; see Payment outcome codes.
error object always carries code and message; doc_url is optional. Some codes include additional machine-readable data, at the locations listed in the reference table.
Most extra keys sit at the top level, as siblings of error: above, status describes the state that refused the operation. HOTEL_NAME_LOW_CONFIDENCE and DESTINATION_LOW_CONFIDENCE carry their recovery data inside error, at error.top_candidates and, when available, error.suggested_retry. Read those nested fields to choose or confirm a candidate before retrying. Continue branching on error.code, and tolerate additional fields at either level.
error.code is a closed enum. The reference table below is the complete set of error.code values: the platform sends no error.code that is missing from it, and every code on it has a producer. It is published as the code enum on ErrorResponse in the OpenAPI document, and this page is kept in step with that enum. Some refusals also carry a sibling code beside error.code with a finer reason; those values are listed under Sibling codes.New codes are added in a minor version and never change meaning. See Versioning & deprecation for what that entitles you to.ApiError with statusCode, see SDK error handling.
Error code reference
An SPT currency refusal (
CURRENCY_UNSUPPORTED) means the trip is quoted in a currency other than USD or EUR. Pay through hosted checkout with the checkout_url from your earlier checkout response, or search again in USD or EUR.
Messages on supplier failures
These codes always carry the same message, and it never includes text from a supplier;details carries none either. A 503 stays 503 UPSTREAM_UNAVAILABLE (with Retry-After) and a 504 stays 504 UPSTREAM_TIMEOUT; neither is folded into a 502. Branch on error.code, not on the message.
A supplier’s refusal of a request that Jinko itself accepted keeps its status and code, with a fixed message instead of the supplier’s words:
A message Jinko writes from its own checks, such as a missing field or a leg selection that does not match the booking, keeps its own text.
Payment outcome codes
Typed payment submissions report payment outcomes inpayment_error.code (or null when there is no payment error), with exactly one recovery.action. These codes are uppercase and share their names with error.code: a refusal before the attempt is accepted carries the same code in both. A 200 can contain a decline; a 202 means pending. Follow the returned recovery action, not the HTTP status alone. The legacy flat SPT body does not use them.
CREDENTIAL_FORMAT_INVALID: 400; fix the request and replay the same key if that key was valid (a rejectedIdempotency-Keyneeds a valid key instead).PAYMENT_TYPE_NOT_ENABLED: 403; stop.PAYMENT_CREDENTIAL_INVALID: credential unusable; correct before acceptance, replacement + new key after cleanup.TRIP_OWNED_BY_OTHER_PAYMENT: 409; stop.IDEMPOTENCY_KEY_REUSED: 409; stop, read the original attempt.ATTEMPT_IN_PROGRESS: poll; competing attempts get 409.CARD_DECLINED: the card was declined; followrecovery.action.PAYMENT_CREDENTIAL_REJECTED: SPT rejection; replacement + new key on a valid quote once quiescent.AUTHENTICATION_REQUIRED: follow the returnedrecovery.action; replace the credential once allowed.AUTHENTICATION_FAILED: poll cleanup, then replacement or recheckout.AUTHENTICATION_ABANDONED: poll, then recheckout.QUOTE_EXPIRED: 410 before acceptance; recheckout.ATTEMPT_TERMINAL: 409 before acceptance (recovery.action: stop). After acceptance it arrives in the normal payment outcome: followrecovery.action(pollduring cleanup, thenretry_new_keyon a valid quote orrecheckoutif it expired).PAYMENT_OUTCOME_UNKNOWN: poll untilrecovery.actionchanges; a new key only when it returnsretry_new_key, orrecheckoutif the quote expired.FULFILLMENT_DISPATCH_DELAYED: poll; no second payment.RECONCILIATION_REQUIRED: contact support.TEMPORARILY_UNAVAILABLE: 503 before acceptance: retry same key; after: poll.
Sibling codes
A few refusals answer with a generalerror.code and put the finer reason in a sibling code field beside error. Branch on error.code first, then on code:
Sibling codes keep the spelling shown here, mostly lowercase; only
QUOTE_EXPIRED is uppercase, because it is also an error.code. Compare them exactly as listed.
On a quote_drift refusal whose quote is still valid, retry_quote_id names the quote to commit again.
Booking lookup and hotel cancellation
Get Booking and Hotel Cancel support two ways to identify a booking:- Guest verification: provide
booking_refandlast_nametogether. - Authenticated owner: provide
booking_refwith a credential authorized for that booking.last_nameis optional in this mode; omitting it does not bypass the ownership check. A tenant key needs thepost_bookingscope and access to the booking’s tenant.
booked_item_id from get_booking and use it throughout preview, commit and status. It may be omitted only when the booking has exactly one hotel item.
The public REST API distinguishes invalid input from a booking the caller cannot access:
The authenticated not-found rule also applies to hotel cancellation preview and the legacy one-shot cancellation. A refused preview returns no quote handle. Builder MCP reports an unknown or inaccessible booking as
structuredContent.status: "not_found"; its error envelope differs from the REST envelope above.
Do not retry a 404 with guessed surnames or identifiers. Confirm the booking reference and the caller’s access. Do not treat a missing last_name alone as a validation failure for an authenticated owner.
Duplicate operations are not an error
Trip edits and checkout handle repeats as follows:- Creating a trip is never idempotent. Two calls to
POST /v1/tripwith notrip_idmake two trips. That is the contract, not a race you lost. Hold thetrip_idfrom the first response. add_itemandupsert_travelersare idempotent by identity. Adding the same offer twice leaves one item; upserting travelers twice leaves one set. You get a 200 and the current state, not a conflict.- Calling
checkouttwice is not an error either. A repeat call on the same trip returns 200 with the samesession_idandcheckout_urlwhile the quote is still valid.
IDEMPOTENCY_KEY_REUSED; changing only the payment credential replays the first accepted attempt (send a new key for a new credential). Follow the payment outcome recovery action before attempting another payment.
The price moved: what happens
This is the single most commonly misunderstood behaviour in the API, so read it before you write your checkout path. By default,checkout proceeds at the new price. It does not fail, and there is no tolerance threshold. Live pricing is a moment in time, and by the time the customer reaches checkout the airline or hotel may quote something different. Jinko takes the provider’s current price and tells you it changed.
Two fields on the affected items[] entry carry the signal:
price_changed on every checkout response. If you ignore it, you will charge a customer a price they never agreed to.
Checkout returns the current price with price_changed: true and price_change (original_money, current_money, and delta_money, whose value is signed) on each affected item. The deprecated price, original_price and price_change.original / .current / .delta carry the same figures in major units. Ask the customer to accept the updated price before taking payment. The former fail_on_price_change option is no longer supported.
When a search offer cannot be priced
POST /v1/trip creates or edits the trip; it does not start pricing. POST /v1/checkout (/v1/book is deprecated) starts pricing and waits for success or failure. A supplier can reject the offer during this step even when Jinko still holds its token. That returns 410 OFFER_EXPIRED before any valid quote exists. Display “This flight offer is no longer available; please search again.”
GET /v1/trip/{id} can then show quote.status: "failed". A failed quote has no payable price, even if an older response also carries expires_at. The quote can include failure_reason ("offer_expired" or "offer_unavailable") and a safe failure_message; expires_at is absent on failed quotes. Polling the trip does not start or retry pricing. Search again, replace the unavailable item from the latest result, and call checkout.
Complete checkout promptly and search again after a delay. Suppliers can withdraw offers before Jinko’s token retention ends; there is no universal safe maximum offer age. See offer freshness for the optional freshness timestamps and how to handle missing values.
After a quote expires
Read the actual payment deadline fromquote.expires_at on a successfully priced get_trip response or expires_at on checkout. Do not calculate it from a fixed duration. Past that instant the price is stale, and the platform will not spend your customer’s money on it.
POST /v1/checkout again on the same trip: an expired quote is never reused, so this re-quotes and returns a fresh expires_at, a fresh total, and fresh agent_spt_params. Then mint a new payment token against those new params and submit it. Check price_changed on the new response before you do, because a re-quote is exactly when a price moves.
Two clocks are in play here, and they are not the same clock:
For hosted payment, call checkout again and send the customer the fresh link and updated price. The hosted page cannot re-quote on its own. The link lifetime is not a field you can read, and an openable link does not make an expired quote payable.
Offers and trips expire on their own clocks. Lifetimes and expiry lists every lifetime in one table.
Fulfillment has its own terminal state for this,
expired_quote, which means the quote ran out after the customer paid but before the booking was placed. That is a different moment from the 410 above, which happens before any money moves. See the flight booking guide for the full fulfillment table.A trip that refuses to change
A trip accepts writes while it isdraft. In any other state, add_item, remove_item and upsert_travelers are refused before anything is written, and so is POST /v1/checkout.
Two codes tell you which kind of refusal you hit, and both carry the trip’s status beside the envelope:
TRIP_EXPIREDmeans the trip lapsed after 24 hours with no activity.statusisexpired. It is not recoverable: build a new trip from fresh offers.TRIP_STATE_CONFLICTmeans the trip is past the point of change.statusnames which:fulfilling,fulfilledorpartially_fulfilledbecause payment already started, orcancelled/failed.
Lapsing is lazy: a read applies it, a write does not. The 24 hours elapsing changes nothing on its own. The lapse is applied on the next read of the trip, and
GET /v1/trip/{id} returns expired on that same call rather than one call later.Until that read happens the trip still sits at its old status, so a write to a trip nobody has read since it lapsed may still succeed. If you are resuming a trip you have not touched in a while, call GET /v1/trip/{id} first and reconcile on what it tells you, rather than relying on the next write to refuse you.A search with no matches is not an error
A livePOST /v1/flight_search in search mode where no offer matches the filters returns HTTP 200 with an empty offers list. It is a success: there is no error envelope. The body carries result: "no_matches" and an instruction to relay. When the request sent a filter that narrows the results, the instruction asks the user which filter to relax; otherwise it asks whether to try other dates or airports.
no_matches then means the filter combination was too restrictive for this route and dates. When the request carried no narrowing filter, instruction reads “Search completed successfully, but no flights were found for this route and dates. Ask the user whether to try other dates or airports before searching again.” Both outcomes are also sent in the X-Jinko-Result and X-Jinko-Instruction response headers. They are deprecated: read the body.
See Filters are always applied.
A search answers no_matches whenever at least one airline source answered, even if another could not be searched. When some sources fail and others return offers, the response is a normal 200 with those offers; it does not say which sources failed. Only a search that could not be completed at all is an error, never an empty list: UPSTREAM_REJECTED, UPSTREAM_ERROR, UPSTREAM_UNAVAILABLE or UPSTREAM_TIMEOUT, with the fixed message listed under Messages on supplier failures. Follow the remedy for that code; retry a 503 or 504 later with the same filters.
These outcomes apply to live search mode only. The CLI exits with code 0 for no_matches, and MCP returns its status: "empty", reason: "no_matches" envelope. See flight_search.
Status code reference
Statuses are a coarser signal than codes, but this is the shape of the space:Reporting issues
Responses do not always includeX-Request-ID. Keep the request ID your client sends, and capture a response request ID when present. Include it, the UTC time, endpoint, HTTP status, and error code in a bug report.
Common gotchas
”All my prices are 100x too high”
A $159.77 fare rendering as $15,977. Money fields carryvalue in minor units: { "value": 15977, "currency": "USD", "decimal_places": 2, "display": "USD 159.77" }. Divide by 10 ** decimal_places before displaying, or show display. If you still read the deprecated trip and checkout total_amount ({ amount, currency }), that one is in major units; do not divide it. See Money & prices.
The mirror symptom, prices 100x too small, is usually a hardcoded / 100 applied to a zero-decimal currency such as JPY or KRW.
”My key says Active in the dashboard but every call is failing”
If the calls come back 402PAYMENT_REQUIRED, the key is fine and the organization is out of credit. Key status and credit are independent: a key stays active while its organization’s balance is empty, because nothing is wrong with the credential.
Top up and the same key resumes immediately. Do not rotate it, and do not retry the 402 in a loop; no number of retries adds credit. See Limits & credits.
”I get 401 on the MCP endpoint with a token that worked yesterday”
If you are using a token saved by the retired CLI OAuth flow, it may fail the MCP issuer check. MCP expects an API key or a token from its own OAuth flow. The CLI now uses API keys: runjinko auth login --key jnk_t_..., or set JINKO_API_KEY.
”Trip says ‘fulfilled’ but I never got a confirmation”
If the user’s email is correct on the trip’scontact and booking_state is completed, check spam. If booking_state remains processing, the provider has not finished confirming the booking; contact support if it persists.
Note that fulfilling is a value of the trip’s top-level status, not of fulfillment.status. The fields have different vocabularies; read booking_state for the booking’s progress. Trip status and booking state explains how they relate. See the flight booking guide for the full fulfillment table.
”My offer token is rejected as expired”
Offer availability has no universal safe maximum age. A supplier can withdraw an offer before its token expires; an unavailable offer can produce 410OFFER_EXPIRED when priced. If the user takes a long time on traveler entry, re-run live pricing (flight_search / hotel_search) before adding to a trip.