Skip to main content
Every Jinko endpoint returns failures in one envelope, and the thing you branch on is error.code:
The error object always has the same three keys: code, message, and an optional doc_url, and it never gains a fourth. Extra keys sit at the top level, as siblings of error, never inside it. Above, status is a sibling of error, not a field within it. Some failures carry one or two of these, and they are the machine-readable half of the remedy: the deadline that was enforced, the state that refused you, the items that moved. A client that reads only error.code is never affected by any of them, now or when a new one is added. The reference table names them per code.
error.code is a closed enum. The list on this page is the complete set: the platform sends no 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.New codes are added in a minor version and never change meaning. See Versioning & deprecation for what that entitles you to.
The SDK converts every one of these to ApiError (or its subclasses AuthError / ValidationError), see SDK error handling.

Error code reference

Retryable and non-retryable failures share status codes, so branch on the code. A 409 PRICE_CHANGED is a question waiting for your answer, a 409 OFFER_UNAVAILABLE can never succeed, and a 409 TRIP_STATE_CONFLICT may just need you to wait for a quote in flight. A generic retry-on-409 or retry-on-502 loop treats all three the same and gets all three wrong.

Duplicate operations are not an error

There is no duplicate-operation code, because nothing on this surface refuses a repeat:
  • Creating a trip is never idempotent. Two calls to POST /v1/trip with no trip_id make two trips. That is the contract, not a race you lost. Hold the trip_id from the first response.
  • add_item and upsert_travelers are 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 checkout twice is not an error either. A repeat call on the same trip returns 200 with the same session_id and checkout_url while the quote is still valid.
So a retry that you are not sure went through is safe to send again on every route except trip creation, where the safe move is to read the trip you already have.

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:
Check price_changed on every checkout response. If you ignore it, you will charge a customer a price they never agreed to.

Opting out: fail_on_price_change

If you would rather be stopped than re-priced, send fail_on_price_change: true on POST /v1/checkout:
When any item has moved, you get 409 instead of a checkout session:
items rides alongside the envelope for a client that wants to show what moved; it is absent when the platform named no specific items. The flag defaults to false, so existing integrations keep the proceed-at-the-new-price behaviour.
A 409 here has no side effects. The trip stays quoted and nothing was charged. Show the new price to your customer, then either call checkout again without the flag to accept it, or re-run live pricing to look for something cheaper.

After a quote expires

A quote is good for 300 seconds, readable as quote.expires_at on get_trip and as expires_at on the checkout response. Past that instant the price is stale, and the platform will not spend your customer’s money on it.
A payment attempted after expires_at is refused, not re-priced. POST /v1/agent_payment/submit answers 410 QUOTE_EXPIRED and creates no payment object, so nothing is authorized, nothing is captured, and the trip and its quote are left exactly as they were. Earlier versions of this page said a late payment was re-quoted at whatever the provider then charged. That was never true on any path, and it is the behaviour this refusal exists to make impossible.
The remedy is two calls. Call 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: A human on the hosted checkout page is handled for you: the page reads the quote deadline, and once it has passed it offers a Refresh price button that re-quotes in place and re-renders the total before the customer can pay. Nobody is charged a stale price, and nobody has to start over. The 4-hour link lifetime is not a field you can read.
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 is draft, quoting, or quoted. 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_EXPIRED means the trip lapsed after 24 hours with no activity. status is expired. It is not recoverable: build a new trip from fresh offers.
  • TRIP_STATE_CONFLICT means the trip is alive but busy or finished. status names which: quoting in flight, fulfilling or fulfilled because the customer already paid, or cancelled / failed.
The distinction matters because the remedies are opposite. “You already paid for this” is something you reconcile by reading the trip and finding the booking; “a quote is in flight” is something you wait out. Before this contract both arrived as one untyped 400 with free text, and neither could be told from the other in code.
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.

Status code reference

Statuses are a coarser signal than codes, but this is the shape of the space:

Reporting issues

Every response includes an X-Request-ID header (also visible in SDK logs as X-Request-ID). Capture it and include it in any bug report; we use it to find the exact request server-side.
Email: dev@gojinko.com.

Common gotchas

”All my prices are 100x too high”

A 159.77farerenderingas159.77 fare rendering as 15,977. Monetary amounts come back in minor units: { "value": 15977, "currency": "USD", "decimal_places": 2 }. Divide by 10 ** decimal_places before displaying, 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 402 PAYMENT_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 the token came from jinko auth login, it is a CLI OAuth token (issuer: Jinko Auth user pool). MCP expects either an API key or an MCP-OAuth token (issuer: AuthKit). They look similar but trip the issuer check. Use a jnk_ key for programmatic MCP access.

”Trip says ‘fulfilled’ but I never got a confirmation”

Confirmation emails go via Resend. If the user’s email is correct on the trip’s contact and the booking status is completed, check spam. If fulfillment.status is stuck on processing or confirming for more than about 6 hours, the upstream provider is slow, common on TravelFusion when the airline is laggy. Note that fulfilling is a value of the trip’s top-level status, not of fulfillment.status. The two fields have different vocabularies. See the flight booking guide for the full fulfillment table.

”My offer token is rejected as expired”

Offer tokens have a short life (about 30 minutes for flights, shorter for some hotel rates). Adding one after that answers 410 OFFER_EXPIRED. If the user takes a long time on traveler entry, re-run live pricing (flight_search / hotel_search) before adding to a trip.