# The Embedded Payments SDK

Mount the hosted payment form inside your own page with the browser loader: options, events, session lifecycle, script pinning, and Content Security Policy.

**Category:** Integration

**Last reviewed:** 5 August 2026

# The Embedded Payments SDK

The embedded payments SDK is a small browser loader that puts the hosted payment form inside your own page. Your page keeps its layout, its styling, and its checkout flow. The card fields are rendered inside a frame the payment host serves, so your page never touches a card number and neither does your server.

This guide is the long-form reference: how the pieces fit together, every option and every event the loader exposes, what the session does over its lifetime, how the script is pinned and upgraded, what your page's Content Security Policy has to allow, and how to read the failures when an embed does not work.

If you have not embedded a payment form before, read [Setting Up a Hosted Payment Page](/help/guides/hosted-payment-page-setup) first: the page you are embedding has to exist and be configured before any of this applies.

## How the pieces fit together

An embedded payment involves three parties and moves in one direction.

1. **Your server creates a session.** It calls the session-create API with an API key, naming the hosted page, the amount, the order reference, and the origin of the page that will hold the frame. The response carries an absolute hosted-page URL.
2. **Your page mounts the frame.** It hands that URL to the loader, which creates an iframe pointing at the payment host and starts listening for messages from it.
3. **The payer pays inside the frame.** The card fields belong to the payment host's document. The submission goes from the payer's browser straight to the payment host.
4. **Events come back to your page.** The frame posts messages describing what happened, and the loader turns them into callbacks you registered.
5. **Your server learns what happened from the webhook.** That is the record. The browser callbacks are what your page shows the person looking at the screen.

Two properties of that shape are worth stating plainly, because they are what the design is for.

**The browser is never told what it may charge.** The amount is a property of the session your server created. There is no amount in the page to edit, and no key in the page to create a different session with.

**Your page cannot read the card fields.** They are in a cross-origin frame, and the browser's same-origin policy is what enforces that. The loader is your code, running in your page, subject to the same rule.

### The order events arrive in

A straightforward approved payment raises callbacks in this order:

```text
onReady -> onSessionLoaded -> onComplete
```

`onReady` means the payment page has loaded its script and is listening. `onSessionLoaded` means the session resolved and carries the layout and the expiry instant. Height reports (`onResize`) can arrive at any point after `onReady` as the form grows and shrinks.

## Loading the SDK

There are two supported script tags, and they pin differently. Both give your page a window global, and the loader's entry points hang off it.

**The brand loader** is the tag most integrations should ship:

```html
<script src="https://pay.your-environment.example/sdk/v1/pay.js"></script>
```

It is a few hundred bytes. It resolves the release the payment host actually has, injects the payment code with the integrity attribute already applied, and assigns the window global. You get the pin without redoing your own tag on every release.

**The pinned artifact** is the alternative, for integrations whose change control requires that the code on the checkout page changes only when they change it:

```html
<script src="https://pay.your-environment.example/sdk/core/{version}/{fingerprint}/{file}"
        integrity="sha384-..."
        crossorigin="anonymous"></script>
```

Every brace above is a value you read from the release manifest for your environment: the version, the fingerprint segment carrying the hash of the artifact's own bytes, the file name, and the matching `integrity` value. Nothing in that tag should be copied out of a document, including this one. See [Script integrity and upgrades](#script-integrity-and-upgrades) below, where the trade-off between the two tags is described in full.

`https://pay.your-environment.example` is a placeholder throughout this guide and does not resolve. Your WinkPG administrator has the payment host for your environment.

### The window global, and why this guide does not name it

The window global the loader assigns is a per-deployment value, configured on the reseller's branding settings, so a white-labelled deployment gets a global carrying its own product name rather than the platform's. There is no fixed name this guide could print that would be correct everywhere.

So the examples below write it as `PaySdk`. Substitute the global name for your deployment, which your administrator can read off the branding settings, and which the integrator documentation generated for your brand already carries.

```js
var PaySdk = window.YourConfiguredGlobalName;
```

## Inline mount

`mount(container, options)` places the form in the page flow, sized to its own content.

```html
<div id="checkout"></div>

<script>
  var payment = PaySdk.mount('#checkout', {
    sessionUrl: SESSION_URL_FROM_YOUR_SERVER,

    onComplete: function (outcome) {
      window.location = '/order/confirmed?ref=' + encodeURIComponent(outcome.transactionId);
    },
    onFailed: function (outcome) {
      showMessage(outcome.responseMessage || outcome.reason || 'That payment was not approved.');
    },
    onCancelled: function () {
      window.location = '/cart';
    },
    onSessionInvalid: function (event) {
      showMessage('This checkout is no longer usable. Start again to get a fresh one.');
    }
  });
</script>
```

The container is an element or a CSS selector. A selector that matches nothing throws immediately, on the page that set it up, rather than failing in front of a payer.

In a single-page application, call `payment.destroy()` when the view unmounts. The loader removes its iframe and its message listener. Leaving them attached across a route change is the one reliable way to end up with two mounts competing for one page.

## Button and modal

`payButton(options)` creates a button you place, and an overlay that mounts the form when the button is clicked.

```html
<div id="pay-here"></div>

<script>
  var checkout = PaySdk.payButton({
    sessionUrl: SESSION_URL_FROM_YOUR_SERVER,
    container: '#pay-here',
    label: 'Pay now',

    onComplete: function (outcome) {
      window.location = '/order/confirmed?ref=' + encodeURIComponent(outcome.transactionId);
    }
  });
</script>
```

Omit `container` to get the button back detached, and place it yourself through the handle.

The overlay is created on the first click, not on page load. It closes itself once the session reaches a terminal outcome: approved, pending, or cancelled. It stays open on a decline, and on an invalid or expired session, because in each of those cases the payment page is showing its own explanation and closing the overlay would replace it with nothing. Set `closeOnComplete: false` to suppress all three automatic closes and drive the transition yourself.

The automatic close happens even when your own `onComplete` throws, so a bug in your receipt code cannot leave the overlay sitting over a finished session.

### Keyboard behaviour of the modal

While the overlay is open it claims two keys and leaves every other key to your page. None of this is configurable and none of it needs anything from you.

`Escape` closes the overlay, taking exactly the path the close control takes: the payment page is asked to cancel the session, then the overlay is torn down. `Tab` and `Shift+Tab` cycle within the dialog rather than walking out into the page behind it. Focus returns to the pay button on every close path, so a keyboard payer is not dropped at the top of the document.

Claiming a key means withholding it. Both keys are intercepted on the document in the capture phase and propagation is stopped there, so a shortcut of yours bound to either will not fire under the payment dialog. That covers handlers on your own elements and handlers on `document` or `window` in the ordinary bubble phase.

The exception, stated because you cannot see it from your side: another listener on `document` or `window` that is itself registered in the capture phase runs alongside the overlay's. It is not silenced, because silencing it would make the outcome depend on which script loaded first. If you have a capture-phase key handler, gate it on your own open-modal state.

The iframe is a single stop in the tab cycle. Its content is a separate document, so the field order inside the payment form belongs to the payment page, and a trap on your side can neither read nor steer it.

## Option reference

Every option below is accepted by both `mount` and `payButton` unless the table says otherwise.

| Option | Default | Meaning |
|---|---|---|
| `sessionUrl` | required | The absolute hosted-page URL your server received from the session-create response. Must be https, and must not carry embedded credentials. Its origin becomes the only origin the mount will ever trust. |
| `autoResize` | `true` | Size the iframe to the content height the payment page reports. Turn it off only if you are sizing the frame yourself from `onResize`. |
| `minHeight` | `320` | Floor for the auto-sized height, in CSS pixels. |
| `maxHeight` | none | Ceiling for the auto-sized height. Setting one on a long form gives you an inner scroll region. |
| `title` | a generic secure-payment label | Accessible name for the iframe. |
| `className` | none | Extra class names for the iframe element. |

`payButton` adds these:

| Option | Default | Meaning |
|---|---|---|
| `container` | none | Element or CSS selector the button is appended to. Omit it to create the button detached and place it yourself. |
| `label` | a generic pay label | Button text. |
| `buttonClassName` | none | Extra class names for the button element. |
| `modalTitle` | falls back to `title` | Accessible name for the modal dialog. |
| `closeLabel` | a generic close label | Accessible name for the modal's close control. |
| `closeOnComplete` | `true` | Close the overlay on a terminal outcome: approved, pending, or cancelled. Never on a decline, and never on an invalid or expired session. |
| `onOpen` | none | Called when the overlay opens. |
| `onClose` | none | Called when the overlay closes, whichever way it closed. |

An invalid `minHeight` or `maxHeight` (not a number, not finite, zero or negative) falls back to the default rather than being applied, so a bad value cannot collapse the frame.

## Event reference

Every callback is optional. Register the ones your checkout needs.

| Callback | Raised when | Terminal |
|---|---|---|
| `onReady` | The payment page has loaded and is listening. | No |
| `onSessionLoaded` | The session resolved. Carries the layout and the expiry instant. | No |
| `onComplete` | The payment was approved. | Yes |
| `onPending` | The payment was accepted for processing with no final outcome yet. | Yes, for your interface |
| `onFailed` | This attempt was not approved. | Not to the SDK. See below, and read it before designing your retry. |
| `onCardSaved` | A payment method was stored without taking a payment. | Yes |
| `onCancelled` | The session was cancelled. | Yes |
| `onSessionInvalid` | The session cannot be paid: expired, consumed, revoked, cancelled, or pointed at the wrong page. | Yes |
| `onResize` | The payment page reported a new content height. Applied for you unless `autoResize` is off. | No |
| `onEvent` | Every message this build of the loader recognises, before the typed callbacks run. Useful for logging. | No |

### `onFailed` is not terminal to the SDK, and this is the one that catches people

Neither the inline mount nor the modal tears itself down on a failure. The payment page keeps its form on screen with its own message, so the payer can read what went wrong and correct their entry.

Show the message and leave the form alone. Redirecting, unmounting, or hiding the form in `onFailed` takes the payment page's explanation away from the person who needs to read it, and on the failures that never reached a processor it strands a payer who was one keystroke away from succeeding.

**Whether the same session can carry another attempt depends on how far the submission got, and the two cases behave differently.** A submission rejected before it reached processing (a request the server refuses on its shape) does not spend the session, so the payer corrects the field and submits again on the same form and it works. A submission that reached the processor and came back declined has spent the session: that is the point at which a payment could have been taken, so the platform treats the link as used whatever the processor decided. The payer needs a fresh session from your server for another attempt.

Which callback reports a spent session depends on how the retry is made, and both are worth handling:

| How the retry reaches the platform | What you receive |
|---|---|
| Submitting again inside the frame that is already mounted | `onFailed`, in its second payload form, carrying a `reason` and `code` naming the session as already consumed. Not `onSessionInvalid`: from the page's point of view this is a submission that failed. |
| Mounting a new frame against the same session URL | `onSessionInvalid` with `kind: 'invalid'` and a `consumed` reason, raised as the session resolves. |

Design your checkout for this, because a decline is the failure that actually reaches production traffic: keep the form up so the payer reads the payment page's message, and make "try a different card" a path that asks your server for a new session rather than one that re-posts the old one. See [Session lifecycle and terminal states](#session-lifecycle-and-terminal-states) below.

### Payload shapes

`onComplete`, `onPending`, and the processor-result form of `onFailed` all carry the same outcome shape:

| Field | Notes |
|---|---|
| `transactionId` | The transaction's identifier, or `null` when no transaction was created. |
| `requestedAmount` | What was submitted for authorization. |
| `approvedAmount` | What was approved. Lower than requested on a partial approval, which is a real outcome worth handling. |
| `last4` | Last four digits of the instrument, already masked upstream. |
| `brand` | Card brand, classified from the leading digits. |
| `authCode` | Processor authorization code, when the payment was approved. |
| `responseMessage` | High-level processor message, such as a decline reason. Safe to show a payer. |
| `status` | Normalized lower-case outcome, such as `approved`, `declined`, or `pending`. |
| `currency` | Always `null` on this surface. Your server knows the currency from the session it created. |

A session that also stored a payment method adds `paymentTokenPublicReference`, `customerId`, and `schemeTransactionId`, which are what a later charge against the stored method needs. Key any card-on-file workflow off the presence of `paymentTokenPublicReference` rather than assuming the fields are there.

`onFailed` has a second form for failures that happen before a processor result exists. It carries `reason`, an optional `code`, and an optional `transactionId`. Read `responseMessage` first and fall back to `reason`, which is what the examples above do.

The smaller payloads:

| Callback | Carries |
|---|---|
| `onSessionLoaded` | `layout` (the rendered layout) and `expiresAtUtc`. |
| `onCardSaved` | `transactionId`, `paymentTokenPublicReference`, `customerId`, `schemeTransactionId`, `last4`, `brand`, and `status`. |
| `onCancelled` | `initiatedBy` and `occurredAtUtc`. |
| `onSessionInvalid` | `kind`, which is `expired` or `invalid`, and `reason`, which is the platform's own lower-case reason or `null`. |
| `onResize` | `height` in CSS pixels, and `synthesized` when the report answered an explicit height request rather than an observed resize. |

### Treat unrecognized values as generic

The reason strings and the message vocabulary both grow additively. This build of the loader ignores message types it does not recognize rather than failing, and your code should do the same with a `reason` or `status` value it has never seen. Fall back to your generic message rather than switching on a list that will not stay exhaustive.

### A callback that throws is contained

If one of your handlers throws, the loader logs a generic warning and carries on, so a bug in your receipt code cannot break the rest of the payment flow. `onEvent` runs before the typed callbacks specifically so a logging handler still sees an event whose typed callback throws.

## The handle

`mount` returns a handle describing the frame:

| Member | Meaning |
|---|---|
| `element` | The iframe element, so your page can style or measure it. |
| `origin` | The origin this mount trusts. |
| `destroyed` | Whether `destroy` has run. |
| `requestHeight()` | Asks the payment page to re-report its content height. |
| `setLocale(locale)` | Asks the payment page to switch locale. The locale must be in the session's configured list or the payment page rejects it. |
| `cancel()` | Asks the payment page to cancel the session. Safe to call more than once. |
| `destroy()` | Removes the iframe and every listener. Safe to call more than once. |

`payButton` returns a handle describing the button:

| Member | Meaning |
|---|---|
| `button` | The button element, so your page can style or move it. |
| `isOpen` | Whether the overlay is currently open. |
| `destroyed` | Whether `destroy` has run. |
| `open()` | Opens the overlay and mounts the payment page. |
| `close()` | Closes the overlay and unmounts the payment page. |
| `destroy()` | Removes the button, the overlay, and every listener. Safe to call more than once. |

The command methods return `false` when the command could not be posted. A `true` means it was sent, not that the payment page acted on it: wait for the matching callback.

There is deliberately no `submit()`. Authorizing a payment stays a deliberate act by the person whose payment method it is, performed inside the frame that collected it.

## Session lifecycle and terminal states

A session is a single, short-lived, single-use permission to take one specific payment. Understanding where it can end is most of what makes an embedded integration behave under real conditions.

**Created.** Your server calls the session-create API. The session records the hosted page, the amount and how it may be changed, the presentation, the origin your page will hold the frame at, and an expiry. It is payable from this moment.

**Loaded.** The frame loads the hosted-page URL and the session resolves. `onSessionLoaded` carries the expiry instant, so your page can show a countdown or refresh the checkout before it lapses.

**Consumed.** A submission that reaches processing spends the session. This holds whatever the processor decided: once the submission has been dispatched, a decline consumes the session exactly as an approval does. A submission rejected on its shape before dispatch does not spend it, which is what lets a payer correct a mistyped field and try again on the same form. That split is the whole of the retry story, and it is the reason a decline is not something the same session can absorb.

**Cancelled.** The parent page asked the payment page to cancel, through `cancel()` on the handle, the modal's close control, or `Escape` in the modal. It is terminal and distinct from an administrator revoking the session.

**Revoked.** An administrator revoked the session from the admin console.

**Expired.** The session passed its expiry. A session that expires while your page has it loaded raises `onSessionInvalid` with `kind: 'expired'`.

Everything terminal that is not a plain expiry arrives as `onSessionInvalid` with `kind: 'invalid'` and a lower-case `reason` naming the case: the session was revoked, already consumed, already cancelled, expired before the page could load it, or pointed at a different hosted page than the one that was requested. Treat the set as open and fall back to a generic message for a value you do not recognize.

Two operational rules follow from this, and both are worth enforcing in code review rather than discovering in production:

- **Create one session per payment attempt, including each retry after a decline.** Sessions are single use, and reusing one never produces a second payment. It reports the reuse instead, through whichever callback matches the path: `onFailed` for a resubmit inside the mounted frame, `onSessionInvalid` for a remount against the same URL, exactly as the table above describes.
- **Keep the expiry short.** A checkout session is not a link to email. Create it when the payer reaches the payment step, not when they add the first item to a basket.

There is one silent case by design. A session that never existed has no recorded parent origin, so there is nowhere to address a message to and nothing is sent. If no event at all arrives within a few seconds of mounting, treat it the same as an invalid session rather than waiting indefinitely.

## Authorizing your page to embed

Two independent settings have to line up before an embed works, and they fail in different ways.

**The hosted page's allowed embedding domains.** This is a page-level list your WinkPG administrator maintains in the admin console, on the payment page itself. It states which sites may frame that page, and the browser is what enforces it. Bare hostnames and wildcard subdomains are both accepted. A page whose list does not cover your site cannot be framed by it, whatever your own code does. This is deliberately not something you can set from your side: a permission granted by whoever is exercising it is not a permission.

**The session's parent origin.** This is set per session, on the session-create call, and it is the exact origin of the page that will hold the frame: scheme, host, and port, with no path, query, or fragment. It must be https. There is no http spelling, not even for local development. The payment page addresses its messages to this origin and to nothing else.

The two are checked against each other. A parent origin whose host is not covered by the page's allowed embedding domains is rejected at session create, rather than producing a session that could never work.

Send your administrator the exact origins you need, one per environment and per subdomain. This is a configuration request rather than a code change, and it is the step most likely to add a day to an integration, so raise it early.

### When an embed is refused

The payment page declares which pages may embed it, and a browser that refuses an embed reports the refusal back to the platform. Those reports are surfaced to the merchant who owns the page, so a blocked embed is visible to the people who can fix it rather than only in a developer's console.

What the report carries is the page that was blocked and the site that tried to embed it. That is normally enough to see immediately that an origin is missing from the allowed embedding domains list, or that a staging site was pointed at a production page. It is also the signal worth watching for the opposite reason: a refusal from a site nobody recognizes is somebody attempting to frame your payment page.

## Script integrity and upgrades

Your checkout page loads code from the payment host. Making that load verifiable matters more here than anywhere else on your site, because a script on a payment page can read the page it is on.

If the bytes behind a script tag can change without the tag changing, then whoever can change those bytes can change what runs on every checkout page that includes it, everywhere, at once, with no deployment on your side and nothing for you to notice. That is a property of how a script is hosted rather than of the script itself. Two mechanisms remove it, and they are meant to be used together.

**The URL names its own content.** Published artifacts are served from a path carrying both a version and a hash of the exact bytes at that path. Change any byte and the hash changes, so the path changes. There is no way to express "the same URL, different content", and there is deliberately no unversioned or latest alias: an alias is exactly the mutable pointer this design exists to remove. The payment host enforces its half by re-deriving the hash from the bytes it is about to serve and refusing to serve a file whose content does not re-derive the hash in its own path.

**Your tag pins the hash.** Subresource Integrity puts the check in the payer's browser: you publish a hash in your own page, the browser hashes what it downloaded, and it refuses to execute a mismatch. On a cross-origin script the tag must also carry `crossorigin="anonymous"`, or the browser silently loads the script without checking it at all.

### Reading the release manifest

The current version, the path segments, and the `integrity` value for every published artifact are listed in a release manifest served from the payment host. It is public, briefly cacheable, and safe to poll from a deployment script that keeps a pinned tag current. Read the values from there rather than copying them out of any document, including this one.

### Choosing between the two tags

| | Brand loader | Pinned artifact |
|---|---|---|
| What you trust | The payment host to name the current release. | Nothing beyond the bytes you pinned. |
| Who applies the pin | The loader, from the release the host actually has. | You, in your own tag. |
| Moving to a new release | Automatic, in one step. | You edit the tag. |
| If the release you pinned is withdrawn | Not applicable. | Your tag stops resolving. |

The brand loader is the default recommendation. Choose the pinned artifact when your change control requires that the code on your checkout page changes only when you change it, and accept that you now own moving to new releases.

If the payment host has no verified release, the brand loader endpoint fails rather than degrading. It does not serve a loader with the integrity attribute dropped in order to keep working, because a pin that disappears quietly is worse than an outage you can see.

### Upgrading, and why upgrades are safe to take

The message protocol between the payment page and the loader is additive within a major version. Message types and payload members may be added; nothing is renamed, removed, or repurposed. A build of the loader ignores message types it does not recognize rather than failing on them, which is what lets the payment page move ahead of the code on your checkout page.

That policy is what makes the two upgrade paths behave sensibly:

- **On the brand loader**, a new release is picked up on the next page load. Nothing in your page has to change, because nothing your page depends on was removed.
- **On a pinned tag**, read the new version and its `integrity` value from the manifest, update the tag, and deploy it like any other change. There is no coordinated cutover with the platform, and an older pinned build keeps working while you schedule it.

A genuinely breaking protocol change means a new protocol major, emitted alongside the current one for a deprecation window, so an integration can move without a coordinated release. The version travels on every message.

Two practical notes:

- **Monitor for a script that stops loading.** A pin that stops resolving looks exactly like a checkout that stopped converting.
- **Rolling back is a payment-host action.** A new release occupies a new path, so publishing never overwrites an earlier release, and the brand loader follows a rollback in one step.

### The origination marker

Every mount appends a marker query parameter to the frame URL naming the loader artifact and its version, so a payment taken through an embedded mount is distinguishable from a direct payment-link visit. It rides on the URL because the command channel into the frame is a closed list and the protocol does not permit inventing a message for this. Nothing is required of you here; it is described so the extra parameter on the frame URL is not a surprise.

## Content Security Policy for your page

This section is about the policy on **your** page, the one that holds the frame. The payment page sends its own policy, which you do not configure and do not need to. If your page sends no policy header today, the embed works, and the recommendation below is still worth adopting.

### The two directives you must allow

| Directive | Value to add | Why |
|---|---|---|
| `script-src` | the payment host origin | Your page loads the SDK from it. |
| `frame-src` | the payment host origin | The SDK creates an iframe pointing at it. |

Miss `script-src` and the window global is never defined, so your own code throws on the first line that touches it. Miss `frame-src` and the mount call succeeds, the iframe element exists in the DOM, and it is permanently blank. The second failure is the one that wastes an afternoon, because nothing in your code errored.

Both are checked against the origin, so one entry covers every path on that host.

### What you do not need to allow

- **`connect-src`.** The loader opens no network connections of its own. It creates an iframe and listens for messages; the payment traffic is the frame's own, governed by the payment page's policy.
- **`'unsafe-inline'` or `'unsafe-eval'`.** The loader needs neither. If you had to add either to make the embed work, something else on your page needed it.
- **`img-src`, `style-src`, `font-src` for the payment form.** Everything the form renders is inside the frame and is governed by the payment page's own policy, not yours.

### A policy to start from

```text
Content-Security-Policy:
  default-src 'self';
  script-src 'self' https://pay.your-environment.example;
  frame-src https://pay.your-environment.example;
  base-uri 'self';
  form-action 'self';
  object-src 'none'
```

Adapt the non-payment parts to your own page. `base-uri` and `object-src` are not about the embed: they are cheap, they close two common injection routes on any page, and a checkout page is where that is worth the least thought.

Roll a new policy out in report-only mode first and read the reports for a few days. On a checkout page specifically, an over-tight policy does not degrade gracefully. It takes payments to zero, and it does it silently for everyone whose browser blocked something while your own browser, with its own extensions and cache, was fine.

### Permissions Policy, which is a different header

Two browser features the payment form needs are governed by `Permissions-Policy` rather than by your content policy: `payment`, which the digital wallet sheets are built on, and `publickey-credentials-get`, which some authentication steps rest on. The loader already delegates both to the frame it creates, so the step from your page down to the payment page is handled.

What is not automatic is the step above it. A document can only delegate a feature it holds itself, so if your page sends a `Permissions-Policy` header that declares either feature without naming the payment host, your page has nothing to hand on and the delegation becomes a no-op.

**If your page sends no such header, there is nothing to do here.** That is the common case. If it does send one, name the payment host in both features:

```text
Permissions-Policy: payment=(self "https://pay.your-environment.example"), publickey-credentials-get=(self "https://pay.your-environment.example")
```

Note the syntax difference: origins are quoted strings and `self` is not.

This failure is worth recognizing because it does not present as a policy problem. Card entry keeps working and only the wallet buttons are missing, because a wallet the browser will not permit is simply never drawn.

### Do not widen the policy to make the embed work

Every entry above is a single origin on two directives. If making the embed work seems to require a wildcard host, `'unsafe-inline'`, or a wide `default-src`, the embed is not what needs it. Widening a checkout page's policy to fix a payment integration trades a large, permanent increase in what that page can be made to load against a problem that has a narrow fix.

## Troubleshooting

Work down this list in order. It is ordered by how often each one is the answer.

| What you see | What it is |
|---|---|
| The form renders and no callback ever fires. | The session's parent origin does not exactly match the origin serving your page. Scheme, host, and port all count, so `https://localhost:8443` and `https://127.0.0.1:8443` are different origins. Locally this usually means the page is being served over http, and the parent origin has to be https. |
| The iframe does not render at all, and the console mentions frame ancestors. | Your origin is not on the hosted page's allowed embedding domains. Your administrator adds it. |
| The session-create call is rejected for the parent origin's format. | It was not a strict https origin. No path, no query, no trailing slash beyond the root, and no http. |
| The session-create call is rejected because the parent origin is not allowed. | The same allowlist, checked at the other end. Same fix. |
| The window global is undefined. | Your `script-src` does not allow the payment host, or the script tag 404s because the host has no deployed release. |
| The iframe is present in the DOM and blank, with no error from your code. | Your `frame-src` does not allow the payment host. |
| Card entry works and the wallet buttons never appear. | Your page sends a `Permissions-Policy` header that does not name the payment host. |
| Everything works from a file opened directly in the browser, except no events. | A page opened from the filesystem has no origin, so the payment page has nowhere to address its messages. Serve the page over https, even locally. |
| A failure event fires and the form stays on screen. | That is correct behaviour, not a defect. The SDK does not tear down on a failure, so the payer can read the payment page's own message. |
| A resubmit after a decline reports the session as consumed, through `onFailed`. | Also correct. A submission that reached the processor spent the session, so the resubmit fails rather than being attempted. Create a fresh session for the retry. |
| Events stopped arriving after a route change. | An old mount was left attached. Call `destroy()` when the view unmounts. |

### Why a rejected message is silent

A message reaching your page has to pass four checks before any callback runs: its origin exactly matches the origin of the session URL you supplied, it came from this mount's own iframe, it carries the outbound marker and the protocol version this build understands, and it agrees with the session identifier the first accepted message established.

Anything failing a check is dropped with no error, no console output, and no reply. That silence is deliberate: a page that explains to a hostile sender why its message was refused is a page that helps that sender construct one that is not refused. The practical consequence when you are debugging is that a missing callback almost always means the first check, and the first check almost always means the parent origin.

## What your page is and is not exposed to

This is the section a security review or a card-data assessment asks about.

Your validation requirements are determined by your acquirer and your assessor, against your whole environment. What follows describes how the integration works so you can answer their questions accurately. It is not a determination of your eligibility for any particular self-assessment questionnaire, and nobody on the platform side can make that determination for you.

**Where the card data goes.** The card fields are rendered by the payment host, inside a frame the payment host serves. The payer types into that frame and that frame submits. Card data travels from the payer's browser to the payment host and never enters your page, your JavaScript, your server, or your logs. The browser's same-origin policy enforces this, and it is not something the integration opts into: your page cannot read inside that frame, and neither can the loader, which is your code running in your page under the same rule.

**What crosses the boundary, inbound.** The session URL, and a small closed set of commands: cancel, change locale, re-report height. That is the whole inbound vocabulary, and there is deliberately no command that submits a payment, so no code on your page can cause a payment method to be charged.

**What crosses the boundary, outbound.** Lifecycle messages, and on a completed payment the fields listed in the payload tables above. No message carries a card number, a security code, a card expiry, a network token, or a wallet cryptogram, and none carries the payer's name, address, phone, or email. That is a property of the payment page, not a filter the loader applies afterwards, so nothing excluded there can be recovered by asking the message differently.

An outsourced-frame integration of this shape is what the shortest e-commerce questionnaire, commonly referred to as SAQ A, is written around. Two qualifications apply. Your other payment channels are assessed too: a merchant who also takes card numbers by phone or on paper is assessed on those regardless of how clean this integration is. And your page does not leave scope entirely, which the next section is about.

### What stays yours

- **The scripts on your checkout page.** Every one of them can read the page it is on. Knowing what they are, why each is there, and detecting when one changes is your responsibility. The content-addressed paths and the integrity pin above are what make the SDK answerable here; the rest of your tag manager is not covered by them.
- **Your content security policy.** See the section above.
- **Your API key.** It creates payment sessions. Treat it as a payment credential: server side only, in a secret store, rotated, and never in a repository, a build log, or a browser.
- **Your server-side integration.** The amount and what is being paid for are decided by your code. Nothing downstream can second-guess a session your server asked for.
- **Your reconciliation.** The browser callbacks tell your page what to show. What happened is what your server records from the webhook.

### The browser is not your record of the payment

A payer whose connection drops between the approval and your redirect has still paid. Reconcile from your server, treat the callbacks as the fast path for the person looking at the screen, and subscribe to the payment webhooks before you switch any traffic to an embedded checkout.

## See also

- [Setting Up a Hosted Payment Page](/help/guides/hosted-payment-page-setup): create and configure the page this SDK embeds, including its allowed embedding domains.
- [Hosted Payment Page Iframe Integration](/help/guides/hpp-iframe-integration): the raw message protocol underneath this loader, for an integration that handles the messages itself.
- [Embedded Payments Compared With In-Page Card Collection](/help/guides/embedded-payments-vs-direct-card-scripts): how this model differs from a direct card-collection script, and how to migrate off one.
- [Webhook Integration](/help/guides/webhook-integration): the record of what actually happened, which is what your server should reconcile against.
- [Reusing a Saved Card with Payment Tokens](/help/guides/reusing-saved-cards): what to do with the stored-credential reference a completion event carries.

## See also

- [All documentation](https://docs.winkpg.io/llms.txt): the machine-readable index of every public page on this site.
