Accept a card payment with the Payment SDK
The handoff token from prebook, mounting the widget, reading the outcome, completing the booking — plus the SDK's options, methods and error names.
The Payment SDK lets your customer pay by card without leaving your checkout. You do not build a card form, you never handle a card number, and you do not call the payment service — the service you already integrate mints the payment for you and hands your page a single string, the handoff token, which is all the browser needs.
Travel does this today: ask for it when you hold a booking, and the response carries the token beside the booking. Every service that needs your customer to pay will hand you the same token in the same way, and the code on this page does not change when it does.
The card fields live inside the vault's iframe — the vault is the PCI-certified processor account TrustPixel operates — so a card number never reaches your page or your server. That is what keeps your integration at the lightest PCI level, SAQ A, and TrustPixel stays merchant of record for the charge.
Every sample uses $BASE_URL for the API host and $API_KEY for your API key. The SDK itself takes no credential.
The handoff token carries a live client secret. Forward it to your page unchanged, never log it, and never put it in a URL.
Add the SDK to your page
One script tag is the whole browser-side install. It defines the global TrustPixelPayment constructor:
<script src="https://js.oex.sh/v1/payment.js"></script>/v1/ is the evergreen channel: compatible fixes reach your checkout minutes after release, with no deploy of your own. To freeze the bytes instead, pin a release and let the browser verify it with Subresource Integrity — <script src="https://js.oex.sh/1.2.3/payment.js" integrity="sha384-…" crossorigin="anonymous">.
With a bundler, install @trustpixel/payment-js and import the loader instead. It carries the TypeScript types and a small injector — no widget code — so the widget still comes from the hosted channel:
import { loadTrustPixelPayment } from "@trustpixel/payment-js/loader";
const TrustPixelPaymentJs = await loadTrustPixelPayment();
const { TrustPixelPayment } = TrustPixelPaymentJs;Outside a browser loadTrustPixelPayment() resolves null — call it again from the client render. Either way, loading the SDK contacts the vault's own origin at page load rather than at mount time; that is the vault's fraud-signal posture, not an implementation accident.
Ask for a token when you hold the booking
Prebook holds the booking and binds its price. Add use_payment_sdk: true and the response carries the token as well:
curl -X POST "$BASE_URL/v1/travel/hotel/prebook" \
-H "x-api-key: $API_KEY" -H "x-idempotency-key: hotel-dubai-1" \
-H "Content-Type: application/json" \
-d '{
"offer_reference": "<offer_reference from Hotel Offers>",
"guests": [{ "first_name": "Amira", "last_name": "Haddad", "is_lead": true }],
"contact": { "first_name": "Amira", "last_name": "Haddad",
"email": "amira.haddad@example.com", "phone": "+442080160509" },
"use_payment_sdk": true
}'{ "data": { "id": "bkg_0192f3a4b5c64d7e8f9a0b1c2d3e4f5a", "status": "READY_FOR_COMPLETE",
"total_amount": { "amount": 506.73, "currency": "USD" },
"expires_at": "2026-11-09T18:00:00.000Z" },
"meta": { "payment_sdk": { "token": "eyJ2IjoxLCJwcm92aWRlciI6IlNUUklQRSIsImtpbmQiOiJwYXltZW50Iiw…" } } }No money has moved yet. Send meta.payment_sdk.token to your page, and keep data.id — it is the booking you complete in step 4. Flights work identically: POST /v1/travel/flight/prebook takes the same flag and answers with the same meta.payment_sdk.
Read the token defensively, because it is on a fresh prebook only:
const token = meta.payment_sdk?.token ?? "";A prebook you replayed with an idempotency key you already used comes back 200 with no meta, and get, list and book never carry one. If you need another token, prebook again with a new idempotency key.
Mount the widget
Give the SDK the token and an element to own:
<div id="payment"></div>const widget = new TrustPixelPayment({ token, target: "#payment" });
const result = await widget.mount().present();mount() renders the card fields and a submit button into #payment, then returns the widget so it chains. present() resolves with the first settled result.
That is the whole browser integration, and it is the same two lines whichever service minted the token. The token says which vault to use, which flow this is, and what that vault needs — so nothing vendor-specific appears in your code, and the day TrustPixel changes vaults your page does not change.
The widget owns its target's contents and replaces them on every mount, so keep your own controls — a Cancel button, your order summary — outside that element.
Read the outcome
present() resolves with { kind: "payment", outcome } for a payment token. The same widget also saves cards, and a card-saving token resolves with { kind: "setup", reference } instead, so check kind first — TypeScript will not let you read outcome until you do. The outcome is in one of three states:
if (result.kind !== "payment") throw new Error("a payment token always yields a payment result");
switch (result.outcome.status) {
case "succeeded": // the customer paid; complete the booking
case "processing": // not settled yet; the booking service decides
case "declined": // outcome.reason and outcome.message say why
}A declined card is a result, never an exception: the promise resolves with an outcome whose status is "declined", with a machine-readable reason such as insufficient_funds and a customer-safe message you can show as-is. Slips the customer can fix — a typo in the number, an incomplete field — never resolve at all: the widget shows the correction in place and keeps waiting, so you write no code for them.
succeeded covers a card that was authorized but not captured yet, which is what a booking's payment is: the money is committed and the hold is on your customer's statement, and TrustPixel takes it when you book in step 4 — held that way on purpose, so a supplier that refuses releases the hold instead of owing a refund. Nothing extra to do, and nothing to tell them apart by: the call you make next is the same one either way.
Complete the booking
Once the customer has paid, confirm the booking with an empty body. Do not send a payment instrument — the payment is already made:
curl -X POST "$BASE_URL/v1/travel/hotel/bookings/bkg_0192f3a4b5c64d7e8f9a0b1c2d3e4f5a/book" \
-H "x-api-key: $API_KEY" -H "Content-Type: application/json" \
-d '{}'{ "data": { "id": "bkg_0192f3a4b5c64d7e8f9a0b1c2d3e4f5a", "status": "CONFIRMED",
"confirmation": { "supplier_reference": "…", "hotel_confirmation_code": "…" } } }This is the call that confirms the stay with the supplier and settles the money. Recording the payment and capturing it are done for you. confirmation appears once the supplier has returned its reference, and hotel_confirmation_code only when the property issues one.
Three refusals are worth handling by name. Booking before the widget has resolved is 422 payment_failed and leaves the booking READY_FOR_COMPLETE, so let the customer finish and book again. Sending payment_instrument_id on a Payment SDK booking is 422 payment_method_unsupported — the two payment paths are exclusive, one per booking. And booking after a declined or voided confirmation is 422 payment_failed and marks the booking FAILED; that booking is spent, so start again from prebook. Booking a booking that is already confirmed returns it unchanged and never charges twice.
The whole page
Everything above, as the page a partner writes. Your own server holds the API key and makes both travel calls:
<script src="https://js.oex.sh/v1/payment.js"></script>
<button id="checkout">Book</button>
<div id="payment"></div>
<p id="outcome"></p>
<script>
document.querySelector("#checkout").onclick = async () => {
// Your server prebooks with use_payment_sdk and returns the token and booking id.
const { token, bookingId } = await fetch("/checkout/hold", { method: "POST" })
.then((r) => r.json());
const result = await new TrustPixelPayment({
token,
target: "#payment",
}).mount().present();
if (result.kind !== "payment") throw new Error("a payment token always yields a payment result");
if (result.outcome.status === "declined") {
document.querySelector("#outcome").textContent = `Declined: ${result.outcome.message}`;
return;
}
// Your server books with {} and answers with travel's booking: its `data`. This is a
// hotel booking; a flight booking carries `supplier_reference` at the top level instead.
const booking = await fetch("/checkout/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bookingId }),
}).then((r) => r.json());
document.querySelector("#outcome").textContent = `Confirmed — ${booking.confirmation.supplier_reference}`;
};
</script>The SDK surface
The constructor takes one object. Only the token and the mount point are required:
| Option | Type | What it does |
|---|---|---|
token | string | The handoff token, forwarded unchanged. Required. |
target | string | HTMLElement | The element the widget renders into, as a CSS selector or the element itself. Required. |
appearance | object | Passed to the vault to style the card fields your own CSS cannot reach. |
wallets | object | Which accelerated-checkout wallets the vault may offer, e.g. { link: "never" }. A card-saving mount never offers Apple Pay or Google Pay. |
And three methods:
| Call | What happens |
|---|---|
mount() | Renders the fields and the submit button into the target, and boots the vault in the background. Returns the widget, so it chains into present(). |
present() | Resolves with the settled result — { kind: "payment", outcome } for a payment token, { kind: "setup", reference } for a card-saving one — or rejects if the mount was cancelled or the vault failed. Calling it twice returns the same promise. |
destroy() | Takes the mount down and rejects a pending present(). Nothing had been submitted, so no money moved. |
A widget is single-use: after destroy(), or once present() has settled, construct a new one to mount again.
Give the customer another attempt
A token belongs to one payment, so a declined card needs a new one. Do not call book — prebook again with a new idempotency key, which mints a fresh token, and mount a new widget into the same target. The new mount replaces the old one rather than stacking beside it.
Prices are re-bound at prebook, so show the customer total_amount again before the second attempt if it moved.
Let the customer cancel
The Cancel button is yours, because only your page knows what cancelling means — hide the form, go back to the room list, drop the hold. Wire it to destroy():
const widget = new TrustPixelPayment({ token, target: "#payment" });
cancelButton.onclick = () => widget.destroy();
try {
const result = await widget.mount().present();
} catch (err) {
if (err.name === "TrustPixelPaymentJsCancelledError") return backToRooms();
throw err;
}Once the customer has submitted and the vault is confirming, destroy() throws instead of cancelling. The attempt is already with the vault by then, and reporting it as cancelled could tell a customer nothing happened while their card was in fact charged — catch that and ask them to wait. A booking you abandon simply lapses at its expires_at.
When the widget throws
Every failure the SDK throws is a TrustPixelPaymentJsError, and each kind names itself on err.name — a stable value in every build, including minified ones. The classes hang off the TrustPixelPaymentJs global for instanceof checks.
err.name | What it means |
|---|---|
TrustPixelPaymentJsConfigError | The constructor input is unusable: a missing or malformed token, or a target that matches no element. |
TrustPixelPaymentJsStateError | The lifecycle was misused: present() before mount(), or destroy() while the vault is confirming. |
TrustPixelPaymentJsCancelledError | Your own destroy() took the mount down. Nothing was submitted. |
TrustPixelPaymentJsVaultError | The vault refused something that is not a card outcome — most often a token that is stale or was minted elsewhere. |
TrustPixelPaymentJsProviderError | The token names a vault this build does not implement. The name is on the error's provider field. |
A card decline is never among them: declines resolve, as in step 3. An empty token — what you get from a replayed prebook — is a ConfigError at construction, which is why step 1 reads it with a string fallback rather than assuming it is there. None of these messages print the token or the client secret.
Style it
The SDK ships no CSS. It renders two elements into your target and stamps them with class names, so everything visual is yours:
.tp-payment-element { /* the container holding the vault's iframe */ }
.tp-payment-submit { /* the submit button: "Pay" */ }The widget sets no margins of its own, so it never fights your layout — space around the mount point is your page's job. The card fields themselves live inside the vault's iframe where your CSS cannot reach; that boundary is what keeps you out of PCI scope, so style them through appearance, which is handed to the vault:
new TrustPixelPayment({
token, target: "#payment",
appearance: { variables: { colorPrimary: "#0a6cff" } },
wallets: { link: "never" }, // also applePay / googlePay
});wallets narrows what the vault may offer alongside the card fields — { link: "never" } keeps Stripe Link's sign-up prompt out of your checkout. Omit the option and the vault's own defaults stand. The control only ever narrows: "auto" cannot switch on a wallet TrustPixel has disabled for the account. A card-saving token is the exception: that mount never offers Apple Pay or Google Pay, whatever wallets says, because TrustPixel does not save device wallets.
Apple Pay and Google Pay appear on a payment only when the conditions for them hold; when one does not, the vault leaves the wallet out without an error and the card fields still work. TrustPixel must have registered your checkout's host for wallets — the host in the customer's address bar on the page that mounts the widget, with every host and subdomain registered on its own, so tell TrustPixel each one you take payments on. The page must be served over HTTPS; inside a cross-origin iframe, the iframe needs allow="payment" and its own host registered too. And the customer's browser and device must support the wallet, with a card in it. A wallet payment resolves present() exactly as a card payment does.
Test it
Use a sandbox API key. The same code produces test-mode tokens and the same SDK renders them — sandbox is a matter of credentials, not a different script. Pay with the vault's standard test cards: 4242 4242 4242 4242 with any future expiry and any CVC settles as succeeded, and 4000 0000 0000 0002 settles as declined. No real card is ever charged in sandbox, and no real room is held.