# 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:** 16 September 2026

# The embedded payments SDK

The embedded payments SDK is a small browser loader that puts the hosted payment page 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. It covers how the pieces fit together, every option and event the loader exposes, and what the session does over its lifetime. It also covers 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 doesn't work.

If you haven't embedded a hosted payment page 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 address.
2. **Your page mounts the frame.** It hands that address 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's 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're 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. The page holds no amount to edit, and no key that could create a different session.

**Your page can't read the card fields.** They're 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
onSessionLoaded -> onReady -> onComplete
```

`onSessionLoaded` arrives first. It means the session resolved, and it carries the layout and the expiry instant. The payment page resolves the session on the server before the form paints, which is why it comes before the page reports itself listening. `onReady` means the payment page has loaded its script and is listening. Height reports (`onResize`) can arrive at any point after `onReady` as the form grows and shrinks.

Read the two as separate signals: `onSessionLoaded` says the session is good, `onReady` says the form is on screen. Code that waits for `onReady` before it will look at a session payload is waiting for something that already happened.

**The first callback can take a while, and the SDK sets no deadline of its own.** On a cold load, the first event has been measured arriving around ten seconds after the frame mounts (September 2026), and work to bring that down is underway. The SDK ships no mount timeout. If you want one, it's yours to write, and it should be generous: a short deadline reports a healthy mount as a dead one. Measure your own environment before you pick a value.

## Add the script to your page

Two script tags are supported, 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's 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 doesn't resolve. Your Syntch administrator has the payment host for your environment.

### The window global, and why this guide doesn't name it

The window global the loader assigns is a per-deployment value, configured on the reseller's branding settings, so a white-labeled deployment gets a global carrying its own product name rather than the platform's. No fixed name this guide could print 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 selected.

```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 the first time the button is selected, 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 can't leave the overlay sitting over a finished session.

### Keyboard behavior 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 isn't 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 won't 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 can't see it from your side: another listener on `document` or `window` that's itself registered in the capture phase runs alongside the overlay's. It's 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 frame 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 address 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 automatically sized height, in CSS pixels. |
| `maxHeight` | none | Ceiling for the automatically 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 can't collapse the frame.

## Event reference

Every callback is optional. Register the ones your checkout needs. This table is grouped by concern, not by time: for the order they arrive in, see "The order events arrive in" above. `onSessionLoaded` reaches you before `onReady`.

| 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 wasn't approved. | Not to the SDK. See below, and read it before designing your retry. |
| `onCardSaved` | A card was stored without taking a payment. Card rail only. | Yes |
| `onAchSaved` | A bank account was stored without taking a payment. The ACH rail's counterpart to `onCardSaved`. | Yes |
| `onCancelled` | The session was cancelled. | Yes |
| `onSessionInvalid` | The session can't 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 |
| `onAuthenticationChallengeOpened` | The card issuer is asking the payer to verify the payment (3-D Secure), in a window on the payment page. Nothing has been charged yet, so this isn't a pending payment. | No |
| `onAuthenticationChallengeClosed` | The issuer's verification window closed. Carries a `reason` of `completed`, `failed`, `timed_out` or `abandoned`. The payment's outcome arrives on `onComplete`, `onFailed` or `onPending` afterward. | No |
| `onEvent` | Every message this build of the loader recognizes, before the typed callbacks run. Useful for logging. | No |

### The two save callbacks are split by rail

Register the one that matches what you let a payer store, or both if your page offers both tenders. A card save raises `onCardSaved`, a bank-account save raises `onAchSaved`, and neither ever raises the other.

**If you already accept bank accounts on a save-only session, add `onAchSaved`.** A bank-account save used to arrive as `onCardSaved` with the card fields all empty. It now arrives as `onAchSaved` with the account's last four and account type, so a page that registers only `onCardSaved` is no longer told when a bank account is stored, and a checkout waiting on that callback stalls. Card saves are unchanged, so a card-only integration needs no edit.

### `onFailed` isn't 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. On the failures that never reached a processor, it also 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 the server refuses on its shape never reaches processing, so it doesn't spend the session. The payer corrects the field, submits again on the same form, and it works. A submission that reached the processor and came back declined has spent the session: that's 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's 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 address | `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. 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`. |
| `onAchSaved` | `transactionId`, `paymentTokenPublicReference`, `customerId`, `bankAccountLast4`, `bankAccountType`, and `status`. There's no `brand` and no `schemeTransactionId`: a bank account has neither, so those members are absent rather than present and empty. |
| `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 doesn't 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 won't 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 can't 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 couldn't be posted. A `true` means it was sent, not that the payment page acted on it: wait for the matching callback.

No `submit()` exists, by design. Authorizing a payment stays an explicit act by the person who owns the payment method, 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's payable from this moment.

**Loaded.** The frame loads the hosted-page address 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 doesn't 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's the reason a decline isn't 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's 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's 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 requested. Treat the set as open and fall back to a generic message for a value you don't 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 address, exactly as the table above describes.
- **Keep the expiry short.** A checkout session isn't a link to email. Create it when the payer reaches the payment step, not when they add the first item to a basket.

One silent case is intentional. A session that never existed has no recorded parent origin, so the payment host has nowhere to address a message and sends nothing. If no event at all arrives, treat it the same as an invalid session rather than waiting indefinitely. Give that wait plenty of room, for the reason given under "The order events arrive in": a cold load can take on the order of ten seconds to raise its first callback, so a short deadline fails healthy mounts.

## 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 Syntch 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 doesn't cover your site can't be framed by it, whatever your own code does. By design, this isn't something you can set from your side: a permission granted by whoever is exercising it isn't a permission.

**The session's parent origin.** This is set per session, on the session-create call, and it's 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. No http spelling is accepted, 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 isn't 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's 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's 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's 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's 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. That happens everywhere, at once, with no deployment on your side and nothing for you to notice. That's a property of how a script is hosted rather than of the script itself. Two mechanisms remove it, and they're meant to be used together.

**The address 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. No way exists to express "the same address, different content," and no unversioned or `latest` alias is published, by design: 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's about to serve and refusing to serve a file whose content doesn't 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 loads the script without checking it at all, and without reporting anything.

### Read the release manifest

The current version, the path segments, and the `integrity` value for every published artifact are listed in a release manifest the payment host serves at `/sdk/core/manifest.json`. The path is root-relative, so it resolves on whichever payment host your integration already addresses:

```bash
curl https://pay.your-environment.example/sdk/core/manifest.json
```

It carries the release's product, version, and content fingerprint, the base path the release is served from, and for every artifact its file name, content-addressed path, content type, size, sha256, and `integrity` value. It's public, cacheable for a short period, and safe to poll from a deployment script that keeps a pinned tag current.

A host with no verified release answers 503 rather than an empty or partial manifest. That's the same posture as the brand loader failing instead of dropping the pin: a deployment fault should be visible rather than served as something that reads like a release.

Read the values from there rather than copying them out of any document, including this one.

### Choose 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 doesn't serve a loader with the integrity attribute dropped in order to keep working, because a pin that disappears without warning is worse than an outage you can see.

### Upgrades, and why they're 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 doesn't 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. No coordinated cutover with the platform is needed, 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 address 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 address because the command channel into the frame is a closed list and the protocol doesn't permit inventing a message for this. Nothing is required of you here; it's described so the extra parameter on the frame address isn't 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 don't configure and don't 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's 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 don't 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 hosted payment page.** 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` aren't about the embed: they're cheap, they close two common injection routes on any page, and a checkout page is where that's 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 doesn't degrade. It takes payments to zero, and it does so without warning 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 hosted payment page 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's 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, you have nothing to do here.** That's 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` isn't.

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

### Don't 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 isn't 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's ordered by how often each one is the answer.

| What you see | What it means |
|---|---|
| The form renders and no callback ever fires. | The session's parent origin doesn't 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 doesn't render at all, and the console mentions frame ancestors. | Your origin isn't 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 wasn't 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 isn't allowed. | The same allowlist, checked at the other end. Same fix. |
| The window global is undefined. | Your `script-src` doesn't 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` doesn't allow the payment host. |
| Card entry works and the wallet buttons never appear. | Your page sends a `Permissions-Policy` header that doesn't 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's correct behavior, not a defect. The SDK doesn't 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 address you supplied.
- It came from this mount's own iframe.
- It carries the outbound marker and the protocol version this build understands.
- 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's 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 isn't 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's 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's not something the integration opts into: your page can't 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 address, and a small closed set of commands: cancel, change locale, re-report height. That's the whole inbound vocabulary, and no command submits a payment, by design, 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's a property of the payment page, not a filter the loader applies afterward, 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 doesn't 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's on. Knowing what each one does, why it's 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 isn't 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's 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 isn't 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 stored payment method 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://devportal-simpay-sbx.winkpg.io/llms.txt): the machine-readable index of every public page on this site.
