Skip to main content

Checkout integration

Written by Product Management

This article is auto-synced from its in-app version in Tai.

Coming soon. This feature isn't available to your company yet.

The Tai Guard SDK (@nezasa/tai-guard) checks a trip with Tai Guard from your booking checkout and shows the risks it finds — tight connections, missing traveller data, entry rules, disruptions at the destination — next to the booking, before it is confirmed.

When you'd use this

Use it when your own checkout page should warn about risks in the itinerary being booked. For example: a traveller is about to book a trip with a 40-minute connection in Lisbon; the checkout shows "Tight connection in Lisbon" with what to do about it, while there is still time to change the flight.

The SDK runs in the browser and calls TAI directly. Your server never forwards the itinerary to TAI; it only issues a short-lived token that works for that one itinerary, in that one browser.

Steps

1. Create an OAuth client for the checkout

A Company Admin creates one OAuth client per company that runs the checkout:

  1. In the Workbench sidebar, click Settings, then API Keys under Users & Access.

  2. Click New API key or OAuth client and choose the OAuth client type.

  3. Set Allowed agents to Tai Guard, Allowed channels to REST API and Permissions to Invoke agents.

  4. Set Allowed Referers to your checkout's origins, for example https://shop.example.com/, each with the trailing slash: a referer matches by prefix, so without it https://shop.example.com.evil.example would match too. Leave Allowed IPs empty: the browser calls TAI from each shopper's own address.

  5. Click Create OAuth Client and copy the Client ID and the client secret. The secret is shown once.

Keep the client secret on your server. It must never reach the browser. Use this client for the checkout only: a server that calls agents itself sends no referer, so it needs a client of its own.

See API keys for rotating the secret and editing restrictions.

2. Issue a token from your server

Add an endpoint to your checkout's backend that the checkout page calls with its key's thumbprint (see step 4). It:

  1. Serializes the itinerary as JSON — the exact string the browser will send.

  2. Computes the SHA-256 of that string's UTF-8 bytes, as lowercase hex.

  3. Requests a token with grant_type=client_credentials, the hash as itinerary_hash, your checkout session's id as sid, the page's key thumbprint as dpop_jkt, and — optionally — your own key for the trip as cache_key.

  4. Checks the token response: itinerary_bound must be true, token_type must be DPoP, sid must equal the session id it sent and cache_key the key it sent (null when it sent none). Otherwise it refuses the token and returns an error to the page — never hand the page a token that came back unbound.

  5. Returns the token and the itinerary string to the page.

The token works only for an itinerary whose bytes hash to itinerary_hash, and only on the calls the SDK makes. With dpop_jkt, it also works only in the browser that holds that key: the SDK signs each call with the key, so a token copied out of the page is refused anywhere else. TAI answers such a token with token_type DPoP. It expires after 15 minutes. Reuse one token for a session while the itinerary and the thumbprint stay the same; request a new one when either changes or the token is about to expire.

cache_key says which checks are "the same trip": a check with the same cache_key, language (the one it is checked in, so hi and en count as one) and audience within 7 days answers from the earlier check instead of starting — and paying for — a new one, even when the itinerary JSON changed in a way that doesn't matter (a refreshed price, a timestamp). After a change to your company's Risk Catalogue settings that grades trips differently, the next check starts afresh. Pick a key that changes whenever the trip does, such as the booking's id and its version: trip-4711:12. It is 1 to 200 characters of letters, digits and . _ : ~ -. Without one, Tai Guard compares the trip as it reads it — its services, dates, places and travellers — and ignores the rest of the JSON.

// Node.js
import { createHash } from "node:crypto";// tripKey is optional, e.g. "trip-4711:12"
export async function taiGuardToken(itinerary, checkoutSessionId, keyThumbprint, tripKey = null) {
  const itineraryJson = JSON.stringify(itinerary);
  const itineraryHash = createHash("sha256").update(itineraryJson, "utf8").digest("hex");
  const credentials = Buffer.from(
    `${process.env.TAI_CLIENT_ID}:${process.env.TAI_CLIENT_SECRET}`,
  ).toString("base64");
  const form = new URLSearchParams({
    grant_type: "client_credentials",
    scope: "agent:execute",
    itinerary_hash: itineraryHash,
    sid: checkoutSessionId,
    dpop_jkt: keyThumbprint,
  });
  if (tripKey) form.set("cache_key", tripKey);  const response = await fetch("https://taitravel.app/api/v1/oauth/token", {
    method: "POST",
    headers: {
      Authorization: `Basic ${credentials}`,
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: form,
  });
  if (!response.ok) throw new Error(`TAI token request failed: ${response.status}`);
  const body = await response.json();
  // Never hand the page a token that isn't bound to this itinerary and session.
  if (
    body.itinerary_bound !== true ||
    body.token_type !== "DPoP" ||
    body.sid !== checkoutSessionId ||
    body.cache_key !== (tripKey || null)
  ) {
    throw new Error("TAI returned a token that is not bound to this checkout");
  }
  return { token: body.access_token, itineraryJson };
}

The same request with curl:

ITINERARY_HASH=$(printf '%s' "$ITINERARY_JSON" | shasum -a 256 | cut -d' ' -f1)curl https://taitravel.app/api/v1/oauth/token \
  -u "$TAI_CLIENT_ID:$TAI_CLIENT_SECRET" \
  -d grant_type=client_credentials \
  -d scope=agent:execute \
  -d itinerary_hash="$ITINERARY_HASH" \
  -d sid="$CHECKOUT_SESSION_ID" \
  -d dpop_jkt="$KEY_THUMBPRINT" \
  ${TRIP_KEY:+-d cache_key="$TRIP_KEY"}

Before passing the token on, check that the response says "itinerary_bound": true and "token_type": "DPoP", and repeats your sid and cache_key.

dpop_jkt is the 43-character value getKeyThumbprint() returns; pass it on unchanged. sid is 1 to 128 characters of letters, digits and . _ : @ -. Rate limits apply per session, so one busy checkout session can't use up another's allowance; your company's overall limit still covers them all.

3. Add the SDK to your checkout

From npm. The package publishes to GitHub Packages under the @nezasa scope, like the TAI Chat SDK — point the scope at that registry in your .npmrc with a token that can read packages:

@nezasa:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NPM_TOKEN}

npm install @nezasa/tai-guard

As a script. Copy dist/tai-guard.iife.js from the package (it is also exported as @nezasa/tai-guard/script) to your own web server and load it with a plain <script> tag. It defines window.TaiGuard, with the same check, start, getKeyThumbprint, resolveLanguage and toHtml functions. It has no dependencies.

4. Check the itinerary

import { check, getKeyThumbprint } from "@nezasa/tai-guard";const keyThumbprint = await getKeyThumbprint();
const { token, itineraryJson } = await fetch("/checkout/tai-guard-token", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ keyThumbprint }),
}).then((r) => r.json());const findings = await check({
  token,
  itineraryJson,
  sender: "tripbuilder",
  language: "de",
});

Get the thumbprint first, then the token, on every page load. getKeyThumbprint() makes the browser's key the first time and keeps it for your checkout's origin, so it usually returns the same value; its private half can't be read out of the browser. If a token is bound to a key this browser doesn't hold, check() rejects with DPOP_KEY_MISMATCH before sending anything.

check() resolves to the list of findings, already written in the language Tai Guard picked for language. An itinerary with no services to check resolves to an empty list. Pass itineraryJson exactly as your server returned it: the SDK sends it unchanged, so the hash still matches.

Pass your shopper's own language; you don't need to know which ones Tai Guard writes in. It writes in the closest one: de-CH in German, nb-NO in Norwegian, and a language it doesn't write in, such as hi, in English, with one warning in the browser console. A check that ends up in English is the same check as one asked in English, so it isn't paid for twice.

A check usually takes several seconds. Show a placeholder while it runs, and don't hold the booking on it.

On a checkout of several pages, start the check early and collect it where you show the findings. Call start() on the first checkout page: it takes the same options as check(), returns as soon as TAI has accepted the check, and leaves it running when the page is left. Call check() with the same options on the page that shows the findings, even after a full page load: it picks up the running or finished check instead of starting another.

// First checkout page, after the thumbprint and token above
await TaiGuard.start({ token, itineraryJson, sender: "tripbuilder", language: "de" });// Any later page: fetch its thumbprint and a token first, as on every page,
// then pass the same options
const findings = await TaiGuard.check({ token, itineraryJson, sender: "tripbuilder", language: "de" });

If the itinerary JSON changes between the two pages but the trip doesn't (a refreshed price, a timestamp), issue a new token for it with the same cache_key: check() still picks up the check start() began. When the trip itself changes — a night added, a flight swapped — change the cache_key too, so check() starts a new check of the trip as it is now.

5. Show the findings

Render them with toHtml:

import { toHtml } from "@nezasa/tai-guard";document.querySelector("#trip-risks").replaceChildren(
  toHtml(findings, {
    language: "de",
    componentHref: (component) => `#service-${component.service_id}`,
  }),
);

toHtml returns the findings in groups, Important first (or by service type or by place, with groupBy). Each card shows the title and the detail, the services it affects (linked when componentHref returns a URL), and when and where it applies; grouped by service type or by place, it also shows how much it matters (Important, Worth Checking, Good to Know). An Affects line with two or more services lists them as bullets. A card says each thing once: under a severity heading it carries no severity badge, a finding about one service its text already names gets no separate Affects line, a date shown once isn't repeated, a place its location heading already names isn't repeated, and the source is named only when it is something other than your itinerary data. It adds its stylesheet to the page once. Or build your own view from the Finding objects — see the Reference below.

6. Handle errors

check() rejects with a TaiGuardError. Test for one with isTaiGuardError(error) and branch on error.code:

Code

What it means

What to do

ITINERARY_MISMATCH

The itinerary sent is not the one the token was issued for

Pass the exact string your server hashed; request a new token when the itinerary changes

ITINERARY_UNREADABLE

Tai Guard could not read the itinerary

Check that sender matches the system the itinerary comes from, and that you send its itinerary JSON unchanged

AUTHENTICATION_REQUIRED

The token is missing, expired or revoked, or the page's origin isn't in Allowed Referers

Request a new token; check the OAuth client's referers

DPOP_KEY_MISMATCH

The token is bound to a key this browser doesn't hold — the thumbprint came from another browser (or another tab, where the browser can't store the key), or the site's data was cleared since; nothing was sent

Request a new token with the thumbprint getKeyThumbprint() returns now

INVALID_DPOP_PROOF

TAI refused the signature the SDK sent with the call; error.details[0].reason names the check it failed

htu: set apiBaseUrl to TAI's own address, not a proxy or an alias of it. iat: the device's clock is more than five minutes off; ask the shopper to set the date and time, then check again. key or scheme: the token was issued for another key, or sent the wrong way; get the thumbprint, then the token, on every page load. jti: a signature was sent twice, as when a request is replayed outside the SDK; check again. Any other reason: report it with error.requestId

RATE_LIMIT_EXCEEDED

This session, or your company as a whole, made too many calls

Wait error.retryAfterSeconds (when set) before trying again

CREDIT_BUDGET_EXCEEDED

Your company's credit budget for the period is used up

Hide the check until a Company Admin raises the budget

AGENT_TIMEOUT, AGENT_RUN_NOT_STARTED, AGENT_OUTPUT_ERROR, SCHEMA_EVALUATION_TIMEOUT

Tai Guard's run timed out, never started or failed on TAI's side

Offer to check again — a repeated check starts a new run

VALIDATION_ERROR

TAI refused the request's parameters, or the itinerary is over 2 MB

Check sender and the itinerary's size

TIMEOUT

No answer within timeoutMs

Offer to check again — the run keeps going and the next check picks it up

ABORTED

Your signal aborted the check

—

NETWORK_ERROR

TAI could not be reached, even after one retry

Offer to check again

FAILED, CANCELLED

The check's run failed or was cancelled without naming a reason

CANCELLED: offer to check again, a repeated check starts a new run. FAILED: report it with error.runId

INVALID_INPUT

An option is invalid; nothing was sent

Fix the call

INVALID_RESPONSE, UNEXPECTED_RESPONSE

TAI's answer was not what the SDK expects

Report it with error.requestId

CRYPTO_UNAVAILABLE

The page is not served over https, or the browser can't make or use the key

Serve the checkout over https; if it already is, report it with the browser and its version

Other codes from the Agent REST API error table pass through unchanged. error.status holds the HTTP status, error.runId the run when one was started, and error.requestId the id to quote when you contact your Nezasa representative.

Example: a TripBuilder checkout

A checkout built on TripBuilder sends TripBuilder's own itinerary JSON with sender: "tripbuilder". Its backend issues the token with the checkout's session id, the page's key thumbprint and no cache_key, so Tai Guard recognizes a repeat check of the trip by its services, dates, places and travellers; the page checks in the traveller's language and links each affected service to its row in the booking summary:

<div id="trip-risks" aria-live="polite"></div>
<script src="/assets/tai-guard.iife.js"></script>
<script type="module">
  try {
    const keyThumbprint = await TaiGuard.getKeyThumbprint();
    const { token, itineraryJson } = await fetch("/checkout/tai-guard-token", {
      method: "POST",
      credentials: "same-origin",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ keyThumbprint }),
    }).then((r) => r.json());
    const findings = await TaiGuard.check({
      token,
      itineraryJson,
      sender: "tripbuilder",
      language: "de-informal",
    });
    document.querySelector("#trip-risks").replaceChildren(
      TaiGuard.toHtml(findings, {
        language: "de-informal",
        componentHref: (component) =>
          component.component_id ? `#component-${component.component_id}` : null,
      }),
    );
  } catch (error) {
    console.warn("Tai Guard check failed", error.code, error.requestId);
  }
</script>

Reference

check(options)

Option

Type

Description

token

string

The token your server issued (required)

itineraryJson

string

The itinerary JSON, exactly as hashed for the token, with no whitespace before or after it (required)

sender

string

The system the itinerary comes from, e.g. "tripbuilder" (required)

language

string

The language of the findings (required), such as your shopper's de-CH. Tai Guard writes in en, de, de-informal (German that addresses the reader with du), fr, pt, it, es, nl, fi, sv, no, da, pl, cs and tr. Any other value is checked in its base language (de-CH and de_ch in de, nb-NO in no, de-CH-informal in de-informal; case doesn't matter), or else in English — never refused for its language. A header-style list such as de-CH,de;q=0.9 is read by its first entry. An empty or blank language is refused with INVALID_INPUT

audience

string

Who the findings are for: traveller, agent or tour_operator. Accepted, but nothing is filtered on it yet

signal

AbortSignal

Aborts the check

timeoutMs

number

Gives up after this many milliseconds; default 60000

apiBaseUrl

string

The TAI to call; default https://taitravel.app. Use TAI's own address: the signature names the URL called, so a proxy in front of TAI breaks a key-bound token

refresh

boolean

true checks the trip again instead of returning an earlier check's answer — for a "Check again" button. TAI runs a new check once the earlier one finished at least a minute ago, also when that one failed; until then, or while it is still running, that check answers as without refresh, so a click that comes too soon is never refused. A new check is billed and limited like any other, so a full credit budget or rate limit can refuse it. A later check() without refresh returns the new check's findings

check() resolves to the list of findings, which also carries two read-only properties:

Property

Type

Description

checkedAt

string | null

When the check that produced the findings finished, as an ISO 8601 timestamp — an earlier check's time when that one answered. null when TAI did not say, as for an itinerary with no services it turned away at once

cached

boolean

true when an earlier check of the same trip had already finished and answered this call, so checkedAt is that check's time, from before your call

Read them off the result itself: a copy of the list — filter, map, or [...findings] — keeps the findings only. Your code that treats the result as a plain list of findings keeps working. The result type is CheckResult.

getKeyThumbprint()

Resolves to the thumbprint of this browser's key (a 43-character string), for your server to pass to the token endpoint as dpop_jkt. The SDK makes the key on the first call and keeps it in the browser's storage for your checkout's origin; later calls, and later page loads, return the same value. Where the browser can't store it, for example in some private windows, the key lasts only for that page load, and the SDK says so once in the browser console. Rejects with CRYPTO_UNAVAILABLE when the page is not served over https.

start(options)

Takes the same options as check(), refresh included. Resolves to { runId, findings, checkedAt, cached } as soon as TAI has accepted the check: runId is the check's run, and findings is the list when TAI already had it (an earlier check of the same trip) and null while the check is still running. checkedAt and cached say what they say on check()'s result; checkedAt is null while findings is. It never waits for the findings and never cancels the check. It rejects with a TaiGuardError like check() does — also when an earlier check of the same trip already failed.

toHtml(findings, options)

Option

Type

Description

language

string

The language of the labels — pass the one you checked in (required). Resolved as check() resolves it, except that an empty or blank one renders in English, with a warning, instead of being refused

groupBy

string

severity (default: Important, then Worth Checking, then Good to Know), service_type (hotels, flights, rental cars, activities, transfers, then trains, buses and ferries; findings that name no service, or only an insurance or other one, under Whole trip, last) or location (the city and country of the service a finding is about, else its place, else its country; Whole trip only for a finding that names no place at all). A city is one group however it is cased, spaced or accented, and a city named without a country joins the group of that city's country when the findings name just one; two cities of one name in two countries stay apart, and so do two names for one city (Roma, Rome). Groups come in trip order, Whole trip last. Each group has a heading and lists its findings by date, then by severity; an event that began before the trip counts from the trip's (or its service's) first day. A finding about two services appears once, under the one it is placed on: the service its service_id and service_type name, else none. Any other value groups by severity, with one warning per value

componentHref

function

(component, finding) => string | null — a link for an affected service. Only http(s) and relative URLs are used, and only where the card lists the service on its own line

theme

string

auto (default, follows the reader's system setting), light or dark

injectStyles

boolean

Adds the stylesheet to the page once; default true

resolveLanguage(language)

Returns the language Tai Guard writes in for language — the one check(), start() and toHtml() use: resolveLanguage("de-CH") is "de", resolveLanguage("hi") is "en". Use it to tell the shopper that the findings are in English. A value that isn't a string, or is blank, gives "en", although check() and start() refuse a blank one. It writes nothing to the browser console; check(), start() and toHtml() warn the first time a value falls back to English. GUARD_LANGUAGES lists every language Tai Guard writes in.

Finding fields

Every finding has id, code, severity, dimension and title; the rest appear when they apply.

Field

Description

id

The finding's id in this result

code

The kind of risk — a stable key whatever the wording

risk_key

The catalogued risk this finding is, when one fits

severity

high, medium or low

severity_basis

Who set the severity, such as default, ai or company

dimension

An internal grouping; use category instead

category, category_group

What the finding is about, e.g. connections_timing in booking, or entry_health in destination

source

What it rests on, e.g. itinerary_data, passolution_regulations, ai_assessment

title

One line, in the requested language

detail

What it means and what to do, in the requested language

affected_components

The booked services it is about, each with component_id, service_id, service_type, name, and start_date / end_date and city / country when known (a flight, train, bus, ferry or transfer is placed where it leaves from, any other service where it starts, else where it ends; country is an ISO 3166-1 alpha-2 code)

service_id, service_type

The one service it is about, when it is about one

day, window_start, window_end

When it applies (YYYY-MM-DD)

deadline

By when to act, when there is a deadline

place, countries, geo_level

Where it applies; countries are ISO 3166 codes

segment

The flight segment it is about

framing

planning (the service is not booked yet) or fulfilment (it is)

evidence

What the finding rests on, e.g. a flight's cancellation and delay rates; sent with some findings only, rendered when present

mitigation

Not sent to a checkout today; rendered when present

The Finding type ships with the package.

CSS custom properties

Set them on the [data-tai-guard] element, for example .checkout [data-tai-guard] { --tai-guard-link: #0050a0; }:

Property

Styles

--tai-guard-font

Font family; inherited from the page by default

--tai-guard-text, --tai-guard-muted

Body and secondary text

--tai-guard-surface, --tai-guard-border

A finding's background and border

--tai-guard-radius, --tai-guard-gap

Corner radius and spacing

--tai-guard-link

Links to affected services

--tai-guard-high, --tai-guard-medium, --tai-guard-low

The severity badges

Every element carries a tai-guard__… class, each group data-group-by and data-group (e.g. high, stay, whole_trip; a place's key is opaque), and each finding its keys as data- attributes (data-severity, data-category, data-category-group, data-code, data-segment, data-day, …), data-service-ids (every service it affects) and data-component-ids (the booking-system id of each one that has one), plus the type, dates and place of the service it is placed on: data-service-type, data-start-date, data-end-date, data-city and data-country (left out when it is placed on none), so you can restyle, or show what the card leaves out, beyond the properties. Colours, type, borders and card padding have no specificity, so any selector of yours overrides them. The margins and list layout of lists, cards and headings carry one class, so your page's plain element rules (such as ul, dl, p or h3 from a CSS framework) leave them alone, while a rule that adds a class, such as .content p, still applies. To change them, use a selector with two classes, such as .checkout .tai-guard__list: one class alone ties with the built-in rule and loses. Type stays yours: a framework's h3 size or dt weight applies to the group headings and field labels.

Tips and limits

  • Send the itinerary string exactly as your server hashed it, without whitespace before or after it. Any change to it — re-serializing it, even reformatting — makes the hash differ and the call fails with ITINERARY_MISMATCH.

  • Checking the same trip again in the same language (the one it is checked in, so hi and en count as one) and for the same audience within 7 days of the first check finishing returns its result rather than starting a second check, so a page reload doesn't cost a second check. "The same trip" is the token's cache_key when you set one, otherwise the trip as Tai Guard reads it.

  • A stored result is up to 7 days old: a flight status or a closure that changed since then without the trip changing is not in it.

  • A check that failed for a reason on TAI's side (it timed out or never started, for example) or was cancelled is tried again when you repeat it, and that new check is billed. One that failed because of the itinerary, such as ITINERARY_UNREADABLE, answers with the same error for 7 days rather than costing a second check. A changed cache_key, language or audience always starts a new check; so does a changed trip when the token carries no cache_key. With one, a changed trip under the same key answers from the earlier check, so change the key with the trip.

  • A timeout or an abort ends the wait, not the check: it finishes in the background and the next identical check picks up its result.

  • The findings are not yet tailored to who reads them. Review what they say before you show them to travellers, and don't present them as written for travellers.

  • The SDK needs a page served over https.

  • Clearing the site's data, or a private window, gives the browser a new key. A token bound to the old one then rejects with DPOP_KEY_MISMATCH; getting the thumbprint and then the token on every page load avoids it.

  • A device clock more than five minutes off makes TAI refuse the signature with INVALID_DPOP_PROOF, error.details[0].reason iat.

  • If your page's Content Security Policy blocks inline <style> elements (the SDK adds one in browsers without constructed stylesheets), or you render inside a shadow root, pass injectStyles: false and include the exported TAI_GUARD_CSS string in your own stylesheet.

  • Each check uses credits from your company's balance, like any agent run.

Related

  • Agent REST API — the endpoints the SDK calls, tokens and the full error table

  • Agent authentication — how the OAuth client compares with the other ways to connect

  • API keys — creating and managing the OAuth client

  • TAI Chat SDK — embedding the TAI chat in your application

Did this answer your question?