diff --git a/examples/apm/index.html b/examples/apm/index.html
index df393ada..e23e6d6d 100644
--- a/examples/apm/index.html
+++ b/examples/apm/index.html
@@ -26,6 +26,7 @@
+
@@ -82,9 +83,11 @@
const headless = document.getElementById('redirect-headless').checked;
document.getElementById('redirect-silent-failure').disabled = !headless;
document.getElementById('redirect-show-loader').disabled = !headless;
+ document.getElementById('redirect-popup-blocked-overlay').disabled = !headless;
if (!headless) {
document.getElementById('redirect-silent-failure').checked = false;
document.getElementById('redirect-show-loader').checked = true;
+ document.getElementById('redirect-popup-blocked-overlay').checked = true;
}
}
@@ -103,6 +106,9 @@
if (document.getElementById('redirect-headless').checked && !document.getElementById('redirect-show-loader').checked) {
redirect.showHeadlessLoader = false;
}
+ if (document.getElementById('redirect-headless').checked && !document.getElementById('redirect-popup-blocked-overlay').checked) {
+ redirect.popupBlockedOverlay = false;
+ }
if (document.getElementById('redirect-overlay-root').checked) {
redirect.actionOverlayMountParent = document.getElementById('action-overlay-root');
}
@@ -211,6 +217,25 @@
}
})
+ // redirect-popup-blocked fires when the headless auto-open was blocked by the browser
+ // (e.g. Safari). By default the SDK shows its own Pay button overlay automatically.
+ // When popupBlockedOverlay: false is set, handle it here instead.
+ apm.on('redirect-popup-blocked', function (data) {
+ if (document.getElementById('redirect-popup-blocked-overlay').checked) return; // SDK overlay or forceUpdate is handling it
+ // Example custom UI: a simple fixed overlay with a retry button
+ var overlay = document.createElement('div');
+ overlay.style.cssText = 'position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;padding:16px;background:rgba(0,0,0,0.5);';
+ var btn = document.createElement('button');
+ btn.textContent = 'Continue to payment';
+ btn.style.cssText = 'padding:0.75em 1.5em;font-size:1rem;cursor:pointer;';
+ btn.onclick = function () {
+ overlay.remove();
+ data.retry();
+ };
+ overlay.appendChild(btn);
+ document.body.appendChild(overlay);
+ })
+
try {
apm.initialise()
} catch (e) {
diff --git a/package.json b/package.json
index 0fbe768b..0fd800c5 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "processout.js",
- "version": "1.8.8",
+ "version": "1.8.9",
"description": "ProcessOut.js is a JavaScript library for ProcessOut's payment processing API.",
"scripts": {
"build:processout": "tsc -p src/processout && uglifyjs --compress --keep-fnames --ie8 dist/processout.js -o dist/processout.js",
diff --git a/src/apm/API.ts b/src/apm/API.ts
index 14cbe83c..64dbedb3 100644
--- a/src/apm/API.ts
+++ b/src/apm/API.ts
@@ -209,6 +209,8 @@ module ProcessOut {
let INITIAL_MAX_RETRIES = 0
let POLLING_TIMEOUT_ID: number | null = null
let POLLING_CANCELLED = false // Add cancellation flag
+ /** Bumped on each makeRequest and on cancelPolling so stale responses / timers cannot duplicate polls. */
+ let POLLING_GENERATION = 0
const isErrorResponse = (data: AuthorizationNetworkResponse | TokenizationNetworkResponse): data is NetworkErrorResponse => {
const hasInvalidFields = 'invalid_fields' in data;
@@ -391,6 +393,13 @@ module ProcessOut {
};
}
+ const requestGen = ++POLLING_GENERATION;
+ if (POLLING_TIMEOUT_ID) {
+ window.clearTimeout(POLLING_TIMEOUT_ID);
+ POLLING_TIMEOUT_ID = null;
+ }
+ POLLING_CANCELLED = false;
+
if (INITIAL_MAX_RETRIES === 0) {
INITIAL_MAX_RETRIES = internalOptions.serviceRetries;
}
@@ -413,6 +422,10 @@ module ProcessOut {
endpoint,
data,
(apiResponse: AuthorizationNetworkResponse | TokenizationNetworkResponse) => {
+ if (requestGen !== POLLING_GENERATION) {
+ return;
+ }
+
if (isErrorResponse(apiResponse)) {
INITIAL_MAX_RETRIES = 0;
@@ -513,11 +526,10 @@ module ProcessOut {
}
}
- // Continue polling in background (only if not cancelled)
+ // Continue polling in background (only if not cancelled and this chain is still current)
if (!POLLING_CANCELLED) {
POLLING_TIMEOUT_ID = window.setTimeout(() => {
- // Double-check cancellation before continuing
- if (!POLLING_CANCELLED) {
+ if (!POLLING_CANCELLED && requestGen === POLLING_GENERATION) {
internalOptions.serviceRetries = INITIAL_MAX_RETRIES
this.getCurrentStep(internalOptions);
}
@@ -554,8 +566,16 @@ module ProcessOut {
return;
},
(req, _, errorCode) => {
+ if (requestGen !== POLLING_GENERATION) {
+ return;
+ }
+
if ((req.status === 0 || req.status > 500) && internalOptions.serviceRetries > 0) {
+ const retryGen = POLLING_GENERATION;
setTimeout(() => {
+ if (retryGen !== POLLING_GENERATION) {
+ return;
+ }
internalOptions.serviceRetries--;
this.makeRequest(method, pathOrOptions, data, internalOptions)
}, TIMEOUT * ((INITIAL_MAX_RETRIES - internalOptions.serviceRetries) + 1));
@@ -629,7 +649,8 @@ module ProcessOut {
}
public static cancelPolling(): void {
- POLLING_CANCELLED = true; // Set cancellation flag
+ POLLING_CANCELLED = true;
+ POLLING_GENERATION++;
if (POLLING_TIMEOUT_ID) {
window.clearTimeout(POLLING_TIMEOUT_ID);
POLLING_TIMEOUT_ID = null;
diff --git a/src/apm/Context.ts b/src/apm/Context.ts
index 35c9f49f..dd56f14a 100644
--- a/src/apm/Context.ts
+++ b/src/apm/Context.ts
@@ -62,6 +62,12 @@ module ProcessOut {
* Append `ActionHandler` iframe modal / new-window overlay to this element instead of `document.body`.
*/
actionOverlayMountParent?: HTMLElement | null
+ /**
+ * When the headless popup is blocked, mount the SDK's built-in Pay button modal
+ * overlay directly on `document.body`. Default `true`.
+ * Set to `false` to suppress the SDK overlay and handle `redirect-popup-blocked` yourself.
+ */
+ popupBlockedOverlay?: boolean
}
}
diff --git a/src/apm/events/APMEventListener.ts b/src/apm/events/APMEventListener.ts
index b4447087..3ed57514 100644
--- a/src/apm/events/APMEventListener.ts
+++ b/src/apm/events/APMEventListener.ts
@@ -60,6 +60,15 @@ module ProcessOut {
paymentState?: string
}
+ // Emitted when a headless redirect auto-open was blocked by the browser (e.g. Safari
+ // popup blocker) and the SDK has surfaced its manual-fallback Pay UI. Fired before
+ // forceUpdate so the merchant can make their container visible first.
+ // `retry` is a bound reference to handleRedirectClick — call it from a real user-gesture
+ // handler (e.g. an onclick) to open the payment tab with a fresh browser gesture.
+ "redirect-popup-blocked": {
+ retry: () => void
+ }
+
"copy-to-clipboard": {
text: string
}
diff --git a/src/apm/references.ts b/src/apm/references.ts
index 3dca5f09..8e6e2ede 100644
--- a/src/apm/references.ts
+++ b/src/apm/references.ts
@@ -34,6 +34,7 @@
///
///
///
+///
///
///
///
diff --git a/src/apm/views/PopupBlockedFallback.ts b/src/apm/views/PopupBlockedFallback.ts
new file mode 100644
index 00000000..311915ec
--- /dev/null
+++ b/src/apm/views/PopupBlockedFallback.ts
@@ -0,0 +1,76 @@
+module ProcessOut {
+ const { div } = elements
+
+ interface PopupBlockedFallbackProps {
+ config: APIRedirectBase & Partial
+ onRetry: () => void
+ }
+
+ /**
+ * Renders the Pay button UI inside the popup-blocked overlay modal.
+ * Used when a headless redirect popup is blocked by the browser — this view
+ * is mounted into a fresh APMPageImpl overlaid on document.body so it is
+ * always visible regardless of the merchant's container visibility.
+ *
+ * Cancel confirmation is handled inline (local state) so it renders inside
+ * the overlay rather than delegating to ContextImpl.context.page, which is
+ * the invisible main container in headless mode.
+ */
+ export class APMViewPopupBlockedFallback extends APMViewImpl {
+ private showingCancelConfirm = false
+
+ private onCancelClick() {
+ ContextImpl.context.events.emit('request-cancel')
+ this.showingCancelConfirm = true
+ this.forceUpdate()
+ }
+
+ private onCancelConfirm() {
+ ContextImpl.context.events.emit('payment-cancelled')
+ }
+
+ private onCancelBack() {
+ this.showingCancelConfirm = false
+ this.forceUpdate()
+ }
+
+ render() {
+ if (this.showingCancelConfirm) {
+ return (
+ Main({
+ config: this.props.config,
+ hideAmount: true,
+ buttons: [
+ Button({ onclick: this.onCancelBack.bind(this) }, 'Back to payment'),
+ Button({ onclick: this.onCancelConfirm.bind(this), variant: 'secondary' }, 'Cancel payment'),
+ ]
+ },
+ div({ className: 'cancel-request' },
+ div({ className: 'cancel-request-message' }, 'Are you sure you want to cancel the payment?')
+ ),
+ )
+ )
+ }
+
+ const redirectLabel = `Pay ${formatCurrency(this.props.config.invoice.amount, this.props.config.invoice.currency)}`
+ return (
+ Main({
+ config: this.props.config,
+ className: 'redirect-page',
+ hideAmount: true,
+ buttons: [
+ Button({ onclick: this.props.onRetry }, redirectLabel),
+ ContextImpl.context.confirmation.allowCancelation
+ ? Button({ onclick: this.onCancelClick.bind(this), variant: 'secondary' }, 'Cancel')
+ : null
+ ]
+ },
+ div({ className: 'heading-container' },
+ Header('Continue to payment'),
+ SubHeader('Click the button below to complete your payment'),
+ )
+ )
+ )
+ }
+ }
+}
diff --git a/src/apm/views/Redirect.ts b/src/apm/views/Redirect.ts
index 310faa62..e8c6c5b5 100644
--- a/src/apm/views/Redirect.ts
+++ b/src/apm/views/Redirect.ts
@@ -9,6 +9,10 @@ module ProcessOut {
/** After a retryable headless error (e.g. pop-up blocked), show the normal Pay / Cancel UI. */
private headlessManualFallback = false
+ /** Overlay mounted on document.body when popup is blocked in headless mode. */
+ private popupBlockedOverlayEl: HTMLElement | null = null
+ private popupBlockedOverlayPage: APMPageImpl | null = null
+
styles = css`
.redirect-headless-loading {
justify-content: center;
@@ -32,6 +36,44 @@ module ProcessOut {
}
}
+ private showPopupBlockedFallbackOverlay() {
+ // Full-screen backdrop with centered content card
+ const overlay = document.createElement('div')
+ overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;z-index:2147483647;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;padding:16px;box-sizing:border-box;'
+ document.body.appendChild(overlay)
+ this.popupBlockedOverlayEl = overlay
+
+ // Inner wrapper gives APMPageImpl a bounded container to render into
+ const content = document.createElement('div')
+ content.style.cssText = 'width:100%;max-width:400px;'
+ overlay.appendChild(content)
+
+ const overlayPage = new APMPageImpl(content)
+ this.popupBlockedOverlayPage = overlayPage
+
+ overlayPage.render(APMViewPopupBlockedFallback, {
+ config: this.props.config,
+ onRetry: () => {
+ this.removePopupBlockedFallbackOverlay()
+ this.handleRedirectClick()
+ },
+ })
+
+ // Auto-clean up when the payment reaches a terminal state
+ const remove = () => this.removePopupBlockedFallbackOverlay()
+ ContextImpl.context.events.on('success', remove)
+ ContextImpl.context.events.on('failure', remove)
+ ContextImpl.context.events.on('payment-cancelled', remove)
+ }
+
+ private removePopupBlockedFallbackOverlay() {
+ if (this.popupBlockedOverlayEl) {
+ this.popupBlockedOverlayEl.remove()
+ this.popupBlockedOverlayEl = null
+ this.popupBlockedOverlayPage = null
+ }
+ }
+
handleRedirectClick() {
ContextImpl.context.events.emit('redirect-initiated')
const pm = this.props.config.payment_method
@@ -60,7 +102,17 @@ module ProcessOut {
if (headless) {
if (!this.headlessManualFallback && failure.code === 'customer.popup-blocked') {
this.headlessManualFallback = true
- this.forceUpdate()
+ // popupBlockedOverlay: true (default) → mount SDK Pay button overlay on document.body,
+ // always visible regardless of container visibility.
+ // popupBlockedOverlay: false → suppress SDK overlay; merchant handles redirect-popup-blocked.
+ if (!redir || redir.popupBlockedOverlay !== false) {
+ this.showPopupBlockedFallbackOverlay()
+ }
+ // Always emit so merchants can provide their own UI if needed.
+ // retry() must be called from a real user-gesture handler.
+ ContextImpl.context.events.emit('redirect-popup-blocked', {
+ retry: this.handleRedirectClick.bind(this),
+ })
return
}
if (this.headlessManualFallback) {
@@ -97,8 +149,8 @@ module ProcessOut {
const redirectLabel = `Pay ${formatCurrency(this.props.config.invoice.amount, this.props.config.invoice.currency)}`;
return (
- Main({
- config: this.props.config,
+ Main({
+ config: this.props.config,
className: "redirect-page",
hideAmount: true,
buttons: [
diff --git a/src/processout/actionhandler.ts b/src/processout/actionhandler.ts
index 4ddbac8b..4dab547e 100644
--- a/src/processout/actionhandler.ts
+++ b/src/processout/actionhandler.ts
@@ -325,6 +325,16 @@ module ProcessOut {
return null;
}
+ // Safari (pattern 2): window.open returns a non-null window that is immediately
+ // closed — treat this as popup-blocked rather than letting the 500ms timer fire
+ // a misleading "customer.canceled" error. Only applies to real popup flows;
+ // MockedIFrameWindow (IFrame/FingerprintIframe) initialises closed as undefined.
+ if ((this.options.flow === ActionFlow.NewTab || this.options.flow === ActionFlow.NewWindow) && newWindow.closed) {
+ error(new Exception("customer.popup-blocked"));
+ refocus();
+ return null;
+ }
+
// We now want to monitor the payment page
var timer = setInterval(function() {
if (!timer) return;
@@ -335,7 +345,6 @@ module ProcessOut {
newWindow.close()
error(new Exception("customer.canceled"))
- // Temporary just to investigate the issue
telemetryClient.reportWarning({
host: window && window.location ? window.location.host : "",
fileName: "actionhandler.ts/ActionHandler.handle.timer",
@@ -357,8 +366,7 @@ module ProcessOut {
clearInterval(timer)
timer = null
error(new Exception("customer.canceled", undefined, { reason: "tab_closed" }))
-
- // Temporary just to investigate the issue
+
telemetryClient.reportWarning({
host: window && window.location ? window.location.host : "",
fileName: "actionhandler.ts/ActionHandler.handle.cancelf",
@@ -536,7 +544,6 @@ module ProcessOut {
error(new Exception("customer.canceled"));
- // Temporary just to investigate the issue
telemetryClient.reportWarning({
host: window && window.location ? window.location.host : "",
fileName: "actionhandler.ts/ActionHandler.listenEvents",