# Hosted payment page iframe integration

Embed the Hosted Payment Page in an iframe and communicate over the postMessage lifecycle protocol.

**Category:** Integration

**Last reviewed:** 15 September 2026

# Hosted payment page iframe integration

The Hosted Payment Page (HPP) supports two embedding modes: redirect mode (you navigate the cardholder to the HPP address) and iframe mode (you embed the HPP inside a parent page and communicate over the `postMessage` lifecycle protocol). This guide covers iframe mode: authorizing a parent origin, the lifecycle event protocol, the command channel, and the security model.

## Two pieces of configuration

Iframe embedding requires two independent settings to line up:

1. **Page-level `AllowedEmbeddingDomains`**: a list of domains (max 20) authorized to embed a given HPP. Wildcards such as `*.example.com` are supported. Raw IP addresses, `localhost`, and entries without a TLD are rejected. This is the security envelope: a page without your origin in its allow list can't be framed by your site.
2. **Per-session `parentOrigin`**: set when creating each session. It must be a valid HTTPS origin whose host matches an `AllowedEmbeddingDomains` entry. If it's omitted, the session is still valid for non-embedded use, but the postMessage channel is disabled fail-closed (no events flow to a parent).

If the host doesn't match, session creation returns `400` with `HostedPaymentPage:HppSession:ParentOriginNotAllowed`.

## The postMessage lifecycle protocol

Once a session has a `parentOrigin`, the embedded HPP emits a stream of `postMessage` events to the parent throughout the session lifecycle. Every event shares one envelope shape; the `data` payload varies by `type`. Filter on the `source` discriminator (`winkpg-hpp`).

A successful payment fires events in this order:

```
session_loaded -> ready -> payment_started -> payment_succeeded -> navigated(to: 'result')
```

Subscribe to `ready` (not `session_loaded`) when you need the iframe to be visually interactive: `ready` fires once the form root has mounted in the DOM.

Key events include `payment_succeeded`, `payment_failed`, `payment_pending`, `height_changed` (for automatic resizing), `validation_failed`, `session_expired`, `session_invalid`, and the save-only pair `card_saved` (a card was vaulted) and `ach_saved` (a bank account was vaulted). If no event arrives within a few seconds, fall back to a timeout and treat it like `session_invalid`: a session that never existed has no `parentOrigin` to address, so it's intentionally silent.

If your page accepts bank accounts on a save-only session, handle `ach_saved`. A bank-account save used to arrive as `card_saved` with the card fields empty; it now arrives as `ach_saved` with the bank account's last four and account type. A page that listens only for `card_saved` is no longer told about an ACH save. Card saves are unchanged.

`card_saved` carries the card's BIN classification beside the last four and the brand: `bin` (the leading digits), `funding` (`Credit`, `Debit`, `Prepaid`, or `Charge`), and `panLength`. Use them to apply your own funding-type rules at save time instead of making a second server call to read the token back. All three are `null` when the card's range didn't resolve, so branch on the value rather than on the key. The card's expiration date is never on this envelope: subscribe to the `HostedPaymentPage.CardSaved` webhook if you need it, which is delivered only to the merchant that owns the card.

## What `payment_started` tells you

`payment_started` fires when the customer submits, after the page's own field validation and before the payment is created. Its payload carries `paymentMethod` (`card` or `bank_account`) and `saveRequested` (`true` when the customer asked for the payment method to be stored). Both keys are always present.

`saveRequested` is the effective answer rather than the raw checkbox state: a page that never offered the save option reports `false`, and a save-only session reports `true`. Treat the `paymentMethod` value set as open, and handle a value you don't recognize rather than failing on it.

Both values are fixed at submit. They describe the submission now in flight, and nothing re-sends this event, so a customer who changes the checkbox or switches tabs afterward doesn't change what you were told. That lets you record the rail and the save intent straight away instead of holding your record open until the completion webhook arrives.

Digital wallets don't fire this event. Apple Pay, Google Pay, and Paze complete through the wallet sheet rather than the page's own submit, so a wallet payment goes straight to its terminal event. Don't wait on `payment_started` to move a wallet flow forward: handle `payment_succeeded`, `payment_failed`, and `payment_pending` for those.

## What the parent receives on a payment

Payment payloads are restricted to fields the public session endpoint already exposes. No PAN, CVV, expiration, network token, wallet cryptogram, or PII is ever included. A successful payment carries the transaction id, requested and approved amounts (compare them to detect a partial approval), masked `last4` and `brand`, the auth code, and the result status. Token fields (`paymentTokenPublicReference`, `customerId`, `schemeTransactionId`) are present only when the cardholder saved a card; key any card-on-file workflow off the presence of `paymentTokenPublicReference`.

## Locking the amount

A session decides who sets the amount the customer pays. Three choices are available. **Customer-entered** puts the customer in control: they type the figure themselves, which suits an open balance or an ad-hoc payment. **Suggested** pre-fills a figure the customer can still change, which suits a suggested donation or a recommended top-up. **Locked** fixes the figure so the customer can't change it, which suits an invoice or a known order total.

On a locked session, the read-only field is the visible part of the guarantee, not the whole of it. The amount set when the session is created is held with the session on Syntch's servers, and every submitted payment is checked against it before anything is authorized. A submission whose amount doesn't match the locked value is rejected. Editing the page in a browser, replaying the request with a different figure, and calling the submit endpoint directly all reach the same outcome: the customer pays the amount you set, or nothing is charged. The check doesn't depend on how the customer pays, so card entry and digital wallets such as Apple Pay and Google Pay are all covered.

A locked session fixes the whole payable total, including any tax, shipping, or convenience fee supplied with the session. The clearest setup is a single figure: fold tax and shipping into the locked amount and send one total. The page then shows the customer exactly what will be charged, and one number reconciles against the payment afterward.

## Two-way command channel

The parent can send a small, allowlisted set of commands back into the iframe: `cancel`, `set_locale`, `prefill`, and `request_height`. Inbound commands carry a distinct discriminator (`winkpg-hpp-cmd`) so an outbound envelope can't be replayed back into the iframe as a command. No programmatic `submit` exists: payment authorization stays user-initiated.

## Hosting in a native app

A native iOS, Android, or Flutter app has no parent page to embed the payment page into. It loads the page in a WebView, which is a top-level document: no parent window, no parent origin, and nothing for the lifecycle events to be posted to. Set `hostChannel` to `NativeWebView` when creating the session, and the page delivers the same events to a message handler your app installs instead.

Send no `parentOrigin` with it. The pair is contradictory, so the request is rejected rather than ignored. You also don't need to send `presentationMode`: a native session renders the chrome-free surface by default, because the standalone page's full-height layout fights the automatic resizing your app does from the height events.

Your app installs one handler named `hppHost`. On iOS that's a `WKScriptMessageHandler` added to the WebView's user content controller, which receives a dictionary. On Android it's `addJavascriptInterface` with a `postMessage(String)` method carrying the `@JavascriptInterface` annotation, which receives a JSON string. Flutter's JavaScript channel gives you the string shape on both platforms. On Flutter for iOS both spellings resolve to the same handler underneath, so the page detects that and delivers each event exactly once: don't install both.

A page configured for Apple Pay or Google Pay still renders those buttons inside a WebView, where the wallet sheet has constraints your app has to satisfy on its own. Restrict the tenders with the `allowed_payment_methods` session directive until you've done that work: it travels as a `prefilledFields` entry carrying a comma-separated list of method keys, not as a field of its own, and it can only narrow what the page already offers.

Event names, payloads, and the sensitive-field boundary are identical to the browser channel, so an app can reuse whatever parsing a parent page already had. Two commands are available back into the page, `cancel` and `request_height`, sent by calling the global `hppHostCommand` function with the command envelope as a JSON string. `prefill` and `set_locale` aren't offered on this channel. A successful `cancel` produces a `cancelled` event whose `initiatedBy` is `"parent"`, the same value a parent page sees.

The security model changes shape. In a browser the protection is the origin check; a WebView has no origins to compare, so the handler your app installs is the trust boundary. Load only session addresses your own server produced, and don't install the handler on a WebView that can navigate elsewhere. The page's `AllowedEmbeddingDomains` list and the frame policy that enforces it don't apply, because a WebView load isn't framing. Events carry the session's short token, which is a bearer credential, so never log a whole event: log the type and the transaction id if you need a trail. A page with bot protection enabled runs its challenge inside the WebView, so exercise one while you're still testing in the sandbox.

## Security model

A receiver written against the raw `addEventListener('message', ...)` API must do all three checks before trusting an event:

```js
window.addEventListener('message', (event) => {
  if (event.origin !== 'https://hpp.winkpg.com') return;   // (1) origin
  const msg = event.data;
  if (!msg || msg.source !== 'winkpg-hpp') return;          // (2) source discriminator
  if (msg.sessionId !== mySessionId) return;                // (3) session scope
  // handle msg.type
});
```

Skipping any one of these weakens the model. Prefer the client helper library, which performs these checks and exposes typed `on(...)` and `send(...)` APIs.

## Quick-start checklist

- Confirm the HPP page has the embedding feature enabled and add your origin to `AllowedEmbeddingDomains`.
- Set `parentOrigin` to your exact origin on each session create.
- Embed the iframe using the session response address and serve the parent over HTTPS.
- Register handlers for at least `ready`, `payment_succeeded`, `payment_failed`, `session_invalid`, and `height_changed`.
- Implement a "no event within N seconds" timeout fallback.
- Hosting in a native app instead of a browser page? Set `hostChannel` to `NativeWebView`, omit `parentOrigin`, and install the `hppHost` handler.

## See also

- [The embedded payments SDK](/help/guides/embedded-payments-sdk): the supported browser loader over this protocol, which performs the origin, source, and session checks above and exposes the lifecycle as callbacks.
- [Setting up a hosted payment page](/help/guides/hosted-payment-page-setup): choose a page mode, walk the creation wizard, and configure page options before you get to embedding it.

## See also

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