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.
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.
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 for the full list.
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.
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" }// 201 Created
{ "checkout_token": "Ab3f...",
"payment_link": "https://..." }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.
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:
Two loading rules that are easy to miss.
type="module"is not supported: a module script is fetched in CORS mode anddocument.currentScriptisnullinside one, which is how the runtime works out which environment served it and therefore where its card iframe lives- The
onerrorhandler is not decoration: a bare tag has no timeout, never checks that the global registered, and cannot retry, so a blocked fetch leavesYavinPaymentsSDKundefined and your next line throws aReferenceError
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.
<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>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)const widget = window.YavinPaymentsSDK.mount(
document.getElementById('card'), // an element, not a selector string
{ checkoutToken: 'Ab3f...', locale: 'fr' }
)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. |
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.
widget.on('ready', () => console.log('field usable'))
widget.on('validity', ({ allValid }) => pay.disabled = !allValid)
widget.on('error', ({ code, message, retriable }) => show(message))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.
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, or by querying the checkout, before releasing goods.
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
}
}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:
| 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
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
}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.
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:
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.
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',
},
})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',
},
})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-Policynaming 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.comandhttps://shop.example.com:8443are different entries - Plain
httpis 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.originon 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.comneeds its own entry if you embed from it - It matches that suffix only, so a look-alike domain such as
https://partner.com.example.netis 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 for each event's structure.
The payment_received payload carries the checkout status:
pending: the checkout is still owed moneyauthorised: the payment is secured but not yet capturedok: the funds are captured and settled. Acapturewebhook marks the move took
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
// 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.