# Online payment drop-in integration

## Card widget integration

Yavin's card widget lets you take card payments on your own checkout page. The card number, expiry date, co-badge and CVC are entered inside a Yavin-owned iframe and never reach your page or your servers, so you keep your own checkout design while staying in the lightest PCI scope.

> 🧩 Scope of v1 — bank card payments only. Meal-voucher cards, Swile/Edenred wallets and Apple Pay are not covered. The widget renders the card fields; you own the pay button, tips UI and everything around it.

> 🚧 The hosted runtime is not serving yet. https://pay.yav.in/sdk/ and https://pay.s.yav.in/sdk/ do not resolve at the time of writing. Everything below is the shape to integrate against — confirm the host with us before you deploy.

---

### How it works

Four participants and one trust boundary (a fifth, the bank's 3-D Secure page, joins only on a step-up). Your backend obtains a checkout token from the Yavin API and hands it to your own frontend; your page then uses it to mount the widget. The card number and CVC exist only inside the Yavin iframe — your page receives field validity and a payment outcome, never card data.

#### The frictionless flow

Most payments complete without the cardholder being challenged: your page mounts the widget and submit() resolves in-page with the outcome.

```mermaid
sequenceDiagram
    autonumber
    participant B as Your backend
    participant A as Yavin API
    participant P as Your page
    participant W as Yavin widget

    B->>A: create checkout with your secret key
    A-->>B: 201 with checkout_token
    Note over B,P: you pass checkout_token to your own frontend, your own way
    B->>P: checkout_token
    P->>W: mount(element, checkoutToken)
    W-->>P: ready, validity, scheme
    P->>W: submit()
    W->>A: card payment
    A-->>W: result
    A-)B: webhook payment_received (or payment_failed on KO)
    opt funds captured
        A-)B: webhook capture — checkout has the funds
    end
    W-->>P: PaymentResult, succeeded / partially_paid / failed
    Note over B: payment_received carries checkout status — pending = ongoing, authorised / ok = paid
    Note over P,W: submit() resolves in-page, the card never enters your page
```

The checkout token is issued by the Yavin API to your backend — never by the widget. The widget only ever receives it from your page.

#### The 3-D Secure step-up

When the issuer requires authentication, the bank's page can't be framed — so the widget navigates the whole tab away. submit() never resolves; the customer returns through the checkout's return URLs instead.

```mermaid
sequenceDiagram
    autonumber
    participant B as Your backend
    participant A as Yavin API
    participant P as Your page
    participant W as Yavin widget
    participant ACS as Bank 3-D Secure page

    Note over P,W: checkout created and widget mounted, see the frictionless flow
    P->>W: submit()
    W-->>P: threeDsChallenge event
    W->>ACS: top-level form POST, tab navigates away
    Note over P,W: your page unloads, submit() never settles

    ACS-->>A: authentication result
    alt Authenticated and paid
        A-)B: webhook payment_received (status authorised / ok)
        opt funds captured
            A-)B: webhook capture — checkout has the funds
        end
        A->>P: redirect to return_url_success with cartId and status
    else Failed
        A-)B: webhook payment_failed (reason)
        A->>P: redirect carrying payment_error — refused, gateway_error or error
    end

    Note over B: trust the webhook and checkout status, not the return-URL params
    Note over P: params are display hints only, re-mount the widget to retry
```

> 🛡️ Why an iframe. The card field is served from Yavin's own origin, so the browser's same-origin policy prevents your page's scripts — and any third-party tag on it — from reading the card. That is what keeps you eligible for the lightest PCI self-assessment: you never see, store or transmit card numbers.

The widget owns card capture and formatting, card-network detection, co-badge selection, the payment request, 3-D Secure and retry bookkeeping. It makes its own network calls; you never construct them.

---

### Step 1 - Create a checkout

A checkout ties one payment to an amount and its return URLs. Create it server-side with your API key — never in the browser — then pass the returned token to your page.

The parameters are exactly those of a payment link (checkout_external_id, amount, items, customer, features and the rest), so they are not repeated here. See [Online payment: Ecommerce API](https://docs.yavin.com/docs/online-payment-ecommerce-api) for the full list.

```bash
POST https://api.yavin.com/api/v5/ecommerce/checkouts/
Yavin-Secret: <your yavin_secret_key>
Content-Type: application/json

{ "checkout_external_id": "order_1234",
  "amount": 2900,
  "items": [
    { "name": "Sneakers", "quantity": 1, "total_amount": 2900 }
  ],
  "return_url_success":   "https://shop.example.com/return",
  "return_url_cancelled": "https://shop.example.com/cancel",
  "webhook_url":          "https://shop.example.com/hooks/yavin" }
```

```json
// 201 Created
{ "checkout_token": "Ab3f...",
  "payment_link": "https://..." }
```

checkout_token is safe to expose to the browser — it is scoped to this one checkout. payment_link is the checkout's shortlink.

#### What is different for the widget

| Field | Direction | Notes |
| --- | --- | --- |
| checkout_token | response | The value you hand to the browser and pass to mount() as checkoutToken. Same value, the two conventions apart: snake_case on the wire, camelCase in JavaScript. |
| payment_link | response | The checkout's shortlink. Before payment it opens Yavin's hosted checkout; once the checkout is paid the same URL redirects to the digital receipt, which makes it a convenient "view your card receipt" link on your confirmation page. |
| amount | request | Integer, minor units. Minimum 100 (€1.00) — a smaller amount is rejected by validation. |
| return_url_success | request | Where a 3-D Secure success returns the customer. Optional, but if you omit it the customer lands on the checkout's own shortlink rather than your site. |

> 🔑 Keep the secret key server-side. It can open checkouts and move money. The browser only ever receives the checkout token, which is scoped to this one checkout. In particular, never expose it through a bundler variable that is inlined into client JavaScript — for example a NEXT_PUBLIC_-prefixed environment variable in Next.js.

> ↔️ Request and response fields are snake_case. The widget's JavaScript options use the JS convention (checkoutToken) — pass the checkout_token you received straight into it.

> 🔁 The checkout token stays valid across failed attempts, so a customer can retry on the same checkout. It becomes unusable once the checkout is settled or cancelled, which the widget reports as checkout_already_paid or session_unusable.

> ⚠️ A checkout_external_id is claimed permanently. The duplicate check ignores the checkout's status, so a cancelled checkout keeps its external id for good and re-using one returns 409. If you replace a checkout after the customer edits their order, give the replacement a distinct external id — deriving it from the amount is a simple way to do that.

---

### Step 2 - Mount the card field

The runtime registers window.YavinPaymentsSDK as it evaluates, so a plain script tag is a complete integration. mount(element, options) renders the field into your container and returns a widget handle. It auto-sizes to its own content — you never manage the iframe's height.

```javascript
<script src="https://pay.yav.in/sdk/v1/yavin.js" onerror="paymentsUnavailable()"></script>
<div id="card"></div>
<button id="pay" disabled>Pay 29.00</button>
```

If you load scripts from a framework rather than writing markup by hand — Next.js, Vue, or any SPA router — inject the tag yourself. This is exactly equivalent to the markup, and the important part is that it is a classic script element, not a module:

```javascript
const tag = document.createElement('script')
tag.src = 'https://pay.yav.in/sdk/v1/yavin.js'
tag.onerror = () => paymentsUnavailable()
tag.onload = () => mountTheField()
document.head.appendChild(tag)
```

```javascript
const widget = window.YavinPaymentsSDK.mount(
  document.getElementById('card'),      // an element, not a selector string
  { checkoutToken: 'Ab3f...', locale: 'fr' }
)
```

> ⚠️ Two loading rules that are easy to miss.

- type="module" is not supported: a module script is fetched in CORS mode and document.currentScript is null inside one, which is how the runtime works out which environment served it and therefore where its card iframe lives

- The onerror handler is not decoration: a bare tag has no timeout, never checks that the global registered, and cannot retry, so a blocked fetch leaves YavinPaymentsSDK undefined and your next line throws a ReferenceError

> 📍 Load the script from the environment your checkout belongs to. The runtime derives its iframe origin from its own script URL, so a token created on sandbox will not work with a runtime served from the production host.

| Option | Type | Notes |
| --- | --- | --- |
| checkoutToken | string | Required. The checkout_token from your backend. mount() throws synchronously if it is missing or blank. |
| locale | string | Field language: "en", "fr" or "es". Defaults to the browser language when it is one of those, otherwise "fr". Any other value throws. |
| theme | Theme | Colour and font tokens — see Styling and localization. |
| labels | Labels | Partial overrides for label, placeholder and error text. Omitted fields keep the widget's own copy for the selected locale. |
| layout | string | "fields" — the only layout implemented. Omit it unless you want to be explicit. |
| returnUrlError | string | Where a failed 3-D Secure returns the customer. Defaults to this page's own URL without query string or fragment, so most integrations never set it. Its origin must be registered for your account. |

Call widget.destroy() to unmount, for example when the customer leaves your payment step. Mounting into a container that already holds a field retires the previous one first, so a re-mount is safe.

---

### Step 3 - Interact with the widget

Subscribe with widget.on(event, handler) and unsubscribe with widget.off. Events carry only non-sensitive state — enough to drive your button and reflect the detected card — never card data. Subscriptions belong to the mount that created them and are dropped by destroy(), so there is nothing to unsubscribe manually.

| Event | Payload | Use it to |
| --- | --- | --- |
| ready | {} | Know the field accepted the handshake and is usable. It never fires for a handshake the field refused, which makes it the only honest readiness signal. |
| validity | { fields: { number, expiry, cvc }, allValid } | Enable your pay button on allValid |
| scheme | { scheme } | Reflect the cardholder's co-badge choice — "cb", "visa" or "mastercard" |
| result | PaymentResult | Observe an attempt's outcome from code that did not call submit() — analytics, for instance. It carries the same value the promise resolves with. |
| threeDsChallenge | {} | Fires immediately before the tab navigates to the bank. Use it to persist any state you need on return. It is not an outcome. |
| error | CheckoutError | Surface any failure the field reports, in or out of an attempt — including boot-time problems no submit() would ever see. |

```javascript
widget.on('ready', () => console.log('field usable'))
widget.on('validity', ({ allValid }) => pay.disabled = !allValid)
widget.on('error', ({ code, message, retriable }) => show(message))
```

> ✅ Co-badge is the cardholder's choice. When a card carries more than one network, the widget shows a selector and the cardholder picks — EU regulation reserves that choice for them, never the merchant. Your page just reflects it via the scheme event.

> 🧯 Gate your pay button on ready and allValid. mount() returning only means the frame started loading; the handshake can still fail afterwards, and an unregistered origin surfaces only as a handshake_timeout error about 20 seconds later.

---

### Step 4 - Take the payment

widget.submit(options) tells the widget to take the payment with the captured card. Pass giftAmount in cents to add a tip chosen in your own UI.

```javascript
pay.onclick = async () => {
  // On a 3-D Secure card this never returns; the widget takes over the tab and
  // the customer comes back via your return URLs (see 3-D Secure).
  const result = await widget.submit()        // optional: { giftAmount: 150 }
  switch (result.status) {
    case 'succeeded':
      showConfirmation(result.transactionId)
      break
    case 'partially_paid':
      collectRest(result.paidAmount, result.amountLeft)
      break
    case 'failed':
      show(result.error.message)              // result.error.retriable says whether to offer a retry
      break
  }
}
```

> ⚠️ submit() does not resolve on a 3-D Secure step-up. The widget navigates the whole tab to the bank and your page unloads, so the await above never returns. The result then arrives through your return URLs and webhooks. Reach the switch only when no step-up was needed.

| status | Fields | Meaning and what to do |
| --- | --- | --- |
| succeeded | transactionId? | Reported successful. Show your confirmation, but confirm server-side before fulfilling. |
| partially_paid | paidAmount, amountLeft, transactionId? | A success, not an error — the attempt took paidAmount but the checkout still owes amountLeft, because part of it had already been paid by another means. Collect the remainder before treating the order as complete. |
| failed | error, transactionId? | A CheckoutError. Render your own copy from error.code, and offer a retry only when error.retriable is true. |

transactionId is what the payment_received / payment_failed webhooks publish as transaction_id, so a browser result joins the server event that fulfils the order. It is optional: the backend omits it where the response names no attempt of your own.

> 🔒 submit() rejects only when there is no outcome at all — the field was destroyed mid-attempt. A refusal is an outcome and resolves as failed. Calling submit() again while an attempt is in flight returns that same attempt's promise rather than authorising twice.

All amounts are integers in minor units (cents).

> ⚠️ Never treat the browser outcome as proof of payment. It is a UX signal. Confirm the settled state server-side from your [webhook](https://docs.yavin.com/docs/online-payment-ecommerce-api), or by querying the checkout, before releasing goods.

---

### 3-D Secure

When the issuer requires a step-up, the bank's authentication page cannot be displayed in a frame - so the widget submits a top-level form POST and the whole tab navigates to it. Your page unloads and submit() never resolves. The customer comes back as follows:

| Result | Lands on | Carrying |
| --- | --- | --- |
| Authenticated and paid | return_url_success, or the checkout's shortlink if you did not set one | cartId and status query parameters |
| Failed | returnUrlError — by default the page the customer started the payment on | payment_error — refused, gateway_error or error |

> ↩️ The failure return needs no setup. The widget runs on your page, so it reads where it is and sends that as the return URL by default — the page the customer started on, without its query string or fragment.

- Pass mount({ returnUrlError }) only to land them somewhere else

- Either way the origin must be registered for your account, exactly like the origin you embed from; an unregistered one is refused before any payment is attempted, reported as origin_not_allowed

On the success return the customer has authenticated: show your confirmation step, but treat the status parameter as a hint and confirm the payment from the webhook or a checkout query before fulfilling.

> 🔁 A retry needs no state from you. The return is a full page load, so your page re-mounts the widget on the surviving checkout token and a fresh submit() starts a new attempt. Attempt identity is server-side; you do not manage it.

---

### Error handling

A payment result — success or failure — reaches you on three channels: the in-page submit() outcome, the return-URL params, and the webhook. The two browser channels give you instant feedback to drive your UI, and the webhook (or a checkout query) is your authoritative confirmation.

| Channel | When it fires | What it carries, and how far to trust it |
| --- | --- | --- |
| The submit() promise, and the result event | Frictionless payments only — never once a 3-D Secure step-up has redirected the tab | A PaymentResult. An immediate UX hint; simply absent whenever the page unloaded for 3-D Secure. |
| The payment_error parameter on the 3-D Secure return | After a failed 3-D Secure step-up | refused, gateway_error or error, with no message. Coarser than the promise, visible to the customer and trivially spoofed — display only. |
| The payment_failed / payment_received webhook | On every attempt, server-to-server | The failure reason and the checkout status. The source of truth — but asynchronous, retried, and possibly never delivered if your endpoint is unreachable. |

The first two are the same event seen from the browser: you get the submit() promise or the return params, never both — the only difference is whether 3-D Secure unloaded the page. The webhook is a separate, authoritative view, and it can contradict what the browser showed.

> ⚠️ The three channels do not share a vocabulary. The error.code below, the payment_error bucket and the webhook reason are three separate tables that happen to share some labels. Never resolve one channel's value through another's table.

#### Error codes

Every failure the widget reports — on the error event, or as result.error — is a CheckoutError:

```typescript
interface CheckoutError {
  code:      CheckoutErrorCode
  category:  'card' | 'authentication' | 'gateway' | 'checkout' | 'session' | 'unknown'
  message:   string    // unlocalised default; re-translate off `code`, not off this string
  retriable: boolean   // whether a retry can plausibly help
  detail?:   string    // raw provider value, for your logs — never display copy
}
```

| code | category | retriable | Meaning |
| --- | --- | --- | --- |
| payment_refused | card | yes | Declined by the issuer. Invite a retry with another card. |
| insufficient_funds | card | yes | The card does not hold enough funds. |
| authorisation_failed | gateway | yes | The payment could not be authorised. |
| three_ds_failed | authentication | yes | 3-D Secure authentication failed. |
| checkout_already_paid | checkout | no | This checkout has already been paid. Create a new one. |
| checkout_amount_changed | checkout | yes | The amount to pay moved. Re-read the checkout and show the new figure before retrying. |
| gateway_unavailable | gateway | yes | The acquirer was unreachable. Transient — invite an immediate retry. |
| payment_failed | unknown | no | The unclassified bucket. Show a generic failure. |
| invalid_session | session | no | The checkout token is unknown or malformed. |
| session_unusable | session | no | The checkout can no longer be used — settled or cancelled. Create a new one. |
| checkout_unavailable | checkout | yes | The checkout could not be reached. |
| handshake_timeout | session | no | The field never confirmed it was ready, after 20 seconds. Usually a slow page — or an origin that is not registered. See Going live. |
| origin_not_allowed | session | no | The returnUrlError you sent names an origin that is not registered for your account, and no registered wildcard admits it. Note this covers the return origin only — an unregistered embedding origin fails silently and still surfaces as handshake_timeout. |

> 🔁 A missing webhook means "unknown", not "failed". Delivery is asynchronous and retried, and a payment can settle without you ever receiving the webhook — for instance if your endpoint was down. Never read failure into silence: if the webhook has not arrived, query the checkout for its status before you act.

> ⚠️ retriable says a person may try again — it does not say your code may.

- Call submit() again from a fresh press of your pay button, on the same mounted field: the attempt slot is cleared when an attempt settles, so the next call starts a genuinely new attempt, and the server owns attempt identity

- Never turn it into an automatic retry loop: a payment whose outcome you did not receive may still have landed, so replaying it unattended is how a double charge happens

- That is why a timeout is retriable: false: an unknown outcome is not a failed one

---

### Styling and localization

Your CSS cannot reach inside the iframe — that isolation is what protects the card. Instead the widget accepts a fixed set of tokens, applied to its own fields. Pass them at mount() or change them live with widget.update(). Tokens you omit keep the widget's defaults; the set below is exhaustive.

```javascript
widget.update({
  theme: {
    textColor:        '#151B23',  // field text
    placeholderColor: '#8B949E',  // placeholder and unfilled slots
    borderColor:      '#DCE1E7',  // field border
    dangerColor:      '#B4231C',  // error border + text
    backgroundColor:  '#FFFFFF',  // field background
    borderRadius:     '8px',      // field corners, any CSS length
    fontFamily:       'system-ui, sans-serif',
  },
})
```

> ⚠️ update() takes theme and locale only. labels is set at mount() and has no live counterpart — only the handshake carries it. checkoutToken and layout choose which frame is booted, so changing either needs a new mount(). Each update() merges its theme onto whatever is already applied, so restyling one token never means repeating the others.

Labels are passed at mount time. All eight are overridable, and anything you omit falls back to the widget's own copy for the chosen locale:

```javascript
window.YavinPaymentsSDK.mount(element, {
  checkoutToken: 'Ab3f...',
  locale: 'fr',
  labels: {
    numberLabel:       'Numéro de carte',
    numberPlaceholder: '1234 5678 9012 3456',
    expiryLabel:       "Date d'expiration",
    expiryPlaceholder: 'MM/AA',
    cvcLabel:          'CVV',
    cvcPlaceholder:    '123',
    numberError:       'Numéro invalide',
    expiryError:       'Date invalide',
  },
})
```

> 🔤 Use system fonts, and always end the stack with a generic family.

- The widget renders in its own document, so web fonts loaded by your page are not available to it: a custom family such as 'Inter' silently falls back

- A bare family name leaves no generic in the stack, so the browser drops to its own default. Write 'Inter, system-ui, sans-serif', never 'Inter'

- mount() throws if the stack does not end in a generic family, rather than letting it fail silently

> 🔒 Loading your own font file into the card field is not supported. A font supplied from outside can be used to infer what the cardholder types, via per-character requests, so the widget only renders fonts already present on the device. If you need a brand font here, talk to us about hosting it on our side.

The iframe sizes itself: it reports its content height and never collapses below a floor while it boots.

---

### Sandbox

Every account comes with a sandbox that behaves like production — the same widget, the same events and outcomes, including the 3-D Secure step-up — but no card is charged and no money moves. Build and test your integration there before switching to live keys.

Point at the sandbox environment. The sandbox is a separate environment with its own URLs, but otherwise behaves exactly like production. Create checkouts server-side against api.sandbox.yavin.com with your sandbox secret key (production is api.yavin.com); the checkout token you get back runs the widget in test mode.

Load the widget from the matching host — https://pay.s.yav.in/sdk/v1/yavin.js on sandbox, https://pay.yav.in/sdk/v1/yavin.js on production. The runtime works out where its card iframe lives from its own script URL, so a sandbox token and a production runtime will not talk to each other. Going live means switching both URLs and your key.

Whitelist your sandbox origins. The origin allowlist is per-environment: register the domains that host the widget in the sandbox list, kept separate from your production list (see Going live).

#### Test cards

Six card numbers force a specific outcome on the sandbox gateway — nothing is charged and nothing reaches a real bank. Any other number, including a real card, is simply approved with no challenge, so these six are the only ones worth scripting against.

| Card number | Outcome |
| --- | --- |
| 4242424242420042 | Visa — approved immediately, no 3-D Secure challenge |
| 5454545454540042 | Mastercard — approved immediately, no 3-D Secure challenge |
| 6767676767670042 | CB — approved immediately, no 3-D Secure challenge |
| 4242424242420034 | Visa — 3-D Secure step-up: lands on a mock authentication page where you choose Approve or Decline |
| 5454545454540034 | Mastercard — 3-D Secure step-up: lands on a mock authentication page where you choose Approve or Decline |
| 6767676767670034 | CB — 3-D Secure step-up: lands on a mock authentication page where you choose Approve or Decline |

> 💳 Use any future expiry date and any 3-digit CVC — only the card number drives the outcome.

---

### Going live

Register your domains. The widget only renders on origins registered for your account, and only accepts instructions from them. Register every origin that will host the field, staging included. The allowlist is per-environment: production and sandbox keep separate lists.

Two independent mechanisms enforce it, and both fail closed — if the lookup fails for any reason, the list is treated as empty rather than permissive:

- the card iframe is served with a Content-Security-Policy naming your registered origins as its permitted frame ancestors, so an unregistered page cannot render the frame at all;

- the frame ignores instructions posted from any origin that is not on the list.

> 🌐 An entry is a bare origin: scheme, host, and port.

- The port is part of it unless it is the scheme default, so https://shop.example.com and https://shop.example.com:8443 are different entries

- Plain http is accepted only for the loopback host

- A trailing slash, a path, a query or credentials make the entry invalid

- If you are unsure what to register, read window.location.origin on the page that will host the widget and register that exact string

> ✳️ One entry can cover a whole sub-domain tree. A leading *. matches sub-domains at any depth: https://*.partner.com admits https://shop.partner.com and https://eu.shop.partner.com.

Two things it deliberately does not do:

- It does not admit the apex: https://partner.com needs its own entry if you embed from it

- It matches that suffix only, so a look-alike domain such as https://partner.com.example.net is never admitted

Scheme and port still have to match exactly, and the wildcard has to be the whole first label: https://*.partner.com is valid, https://*partner.com and https://shop.*.com are not.

The same entry covers returnUrlError, so a failure return may land on any sub-domain a registered wildcard admits.

> ⏱️ An unregistered origin looks like a slow page, not a permission error. Both mechanisms above are silent by design — the frame simply never completes its handshake — so the widget reports handshake_timeout after 20 seconds rather than a dedicated code. If mounting hangs and then times out in an environment you have just deployed, check the allowlist first.

Confirm server-side. Yavin posts to your webhook_url on every payment attempt: payment_received on a successful one, payment_failed (with a reason) on a KO. See the [webhook reference](https://docs.yavin.com/docs/online-payment-ecommerce-api) for each event's structure.

The payment_received payload carries the checkout status:

- pending: the checkout is still owed money

- authorised: the payment is secured but not yet captured

- ok: the funds are captured and settled. A capture webhook marks the move to ok

Fulfil once the checkout is authorised or ok, or wait for ok if you require captured funds before releasing goods. Release goods on that server-side truth, never on a browser outcome or return-URL parameter.

---

### Widget reference

```typescript
// The runtime registers this global as it evaluates.
window.YavinPaymentsSDK.mount(
  container: HTMLElement,          // an element — a selector string is not accepted
  options: {
    checkoutToken: string          // the `checkout_token` from the create-checkout call
    locale?:       'en' | 'fr' | 'es'
    theme?:        Theme
    labels?:       Partial<Labels>
    layout?:       'fields'
    returnUrlError?: string      // 3DS failure return; defaults to this page's origin + pathname
  }
): YavinPaymentsField

interface YavinPaymentsField {
  on(event: 'ready' | 'validity' | 'scheme' | 'result' | 'threeDsChallenge' | 'error', cb): void
  off(event, cb): void
  submit(opts?: { giftAmount?: number }): Promise<PaymentResult>
  update(opts: { theme?: Theme; locale?: 'en' | 'fr' | 'es' }): void
  destroy(): void
}

type PaymentResult =
  | { status: 'succeeded';      transactionId?: string }
  | { status: 'partially_paid'; paidAmount: number; amountLeft: number; transactionId?: string }
  | { status: 'failed';         error: CheckoutError;                   transactionId?: string }

interface Theme {
  textColor?: string; placeholderColor?: string; borderColor?: string
  dangerColor?: string; backgroundColor?: string; borderRadius?: string
  fontFamily?: string   // must end in a CSS generic family
}

interface Labels {
  numberLabel: string; numberPlaceholder: string
  expiryLabel: string; expiryPlaceholder: string
  cvcLabel:    string; cvcPlaceholder:    string
  numberError: string; expiryError:       string
}

// mount() throws synchronously on a missing or blank checkoutToken, an unsupported
// locale, or a fontFamily with no generic family. Everything else is reported
// through the `error` event.
//
// A 3-D Secure step-up resolves via full-page redirect to your return URLs;
// on a step-up submit()'s promise never settles.
```
