Skip to main content

UI Integration Guide

This guide explains how to embed the Paysense Payroll UI inside an <iframe> in your own application.

Overview

The embed solution works as follows:

  1. You generate a short-lived encrypted authentication code server-side using a shared AES-256-GCM key.
  2. You construct an iframe URL containing that code as a ?code= query parameter.
  3. The Paysense app detects the code, exchanges it for a JWT, and authenticates the user transparently.
  4. The payroll UI renders inside your iframe, with no login screen or redirect.

The user's credentials are never exposed to your application. The encrypted code only ever contains a username and an optional expiry.

Prerequisites

Before you begin, you will need the following from Paysense:

ItemDescription
AES-256-GCM encryption keyA 32-byte key, provided as a Base64-encoded string, specific to your tenant
Tenant subdomainYour Paysense subdomain (e.g. acme.iopayroll.com)
Allowed origin registrationYour application's origin must be registered in the Tenant Admin portal before the browser will permit framing

Step 1: Generate an Embed Code (Server-Side)

Embed codes must be generated on your server, never in the browser. Exposing the encryption key to client-side code would allow anyone to forge codes for arbitrary users.

The code is a Base64-encoded AES-256-GCM encrypted JSON payload:

{
"username": "user@example.com",
"expiry": "2026-01-01T12:00:30Z" // optional
}

Payload Fields

FieldTypeRequiredDescription
usernamestringYesThe email address of the user to authenticate
expirystring (ISO 8601 UTC)RecommendedUTC timestamp after which the code is rejected. Recommended: 60 seconds from now

Encryption Spec

PropertyValue
AlgorithmAES-256-GCM
Key size32 bytes (256-bit), Base64-encoded
Nonce size12 bytes (96-bit), randomly generated per code
Tag size16 bytes (128-bit)
Output binary layout[Nonce (12 bytes)][Ciphertext][Auth Tag (16 bytes)]
Output encodingStandard Base64

Code Samples

using System;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

public static string GenerateEmbedCode(string base64Key, string username, TimeSpan? validity = null)
{
var payload = new
{
username,
expiry = validity.HasValue
? DateTime.UtcNow.Add(validity.Value).ToString("o")
: (string?)null
};

var json = JsonSerializer.Serialize(payload,
new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull });

var key = Convert.FromBase64String(base64Key);
var plaintext = Encoding.UTF8.GetBytes(json);

var nonce = new byte[12];
RandomNumberGenerator.Fill(nonce);

var ciphertext = new byte[plaintext.Length];
var tag = new byte[16];

using var aesGcm = new AesGcm(key, 16);
aesGcm.Encrypt(nonce, plaintext, ciphertext, tag);

var combined = new byte[nonce.Length + ciphertext.Length + tag.Length];
Buffer.BlockCopy(nonce, 0, combined, 0, nonce.Length);
Buffer.BlockCopy(ciphertext, 0, combined, nonce.Length, ciphertext.Length);
Buffer.BlockCopy(tag, 0, combined, nonce.Length + ciphertext.Length, tag.Length);

return Convert.ToBase64String(combined);
}

// Usage
var code = GenerateEmbedCode(
base64Key: Environment.GetEnvironmentVariable("PAYSENSE_EMBED_KEY")!,
username: "user@example.com",
validity: TimeSpan.FromSeconds(60)
);

Step 2: Construct the iframe URL

Append the generated code to any Paysense route as a ?code= query parameter:

https://<your-tenant>.iopayroll.com/<route>?code=<generated-code>

Available Routes

RouteDescription
/business/{businessId}/dashboardBusiness dashboard
/business/{businessId}/employeesEmployee list
/business/{businessId}/employees/{employeeId}/detailsEmployee details
/business/{businessId}/employees/{employeeId}/bankEmployee bank details
/business/{businessId}/employees/{employeeId}/taxEmployee tax
/business/{businessId}/employees/{employeeId}/superEmployee superannuation
/business/{businessId}/paycyclesPay cycles
/business/{businessId}/payrunsPay runs
/business/{businessId}/super/contributionsSuper contributions
/business/{businessId}/stpSingle Touch Payroll
/business/{businessId}/detailsBusiness details
/business/{businessId}/reports/audit-logsAudit log reports
/business/{businessId}/reports/webhook-logsWebhook log reports

Replace {businessId} and {employeeId} with the relevant identifiers for the authenticated user's business context.

note

The table above lists the most commonly embedded routes. Any authenticated route in the Paysense app accepts the ?code= query parameter, so you are not limited to the routes listed here.


Step 3: Render the iframe

<iframe
src="https://acme.iopayroll.com/business/42/employees?code=..."
style="width: 100%; height: 800px; border: none;"
title="Payroll"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads"
referrerpolicy="strict-origin-when-cross-origin"
></iframe>

sandbox Attribute

The following sandbox tokens are required:

TokenReason
allow-scriptsRequired for the React application to run
allow-same-originRequired for sessionStorage access - tokens and auth state are stored there; without this the sandboxed frame is treated as a null origin and cannot access storage
allow-formsIncluded defensively. All forms use React Hook Form with event.preventDefault(), so no form navigates to an action URL, but omitting this can cause unexpected behaviour in some browsers
allow-popupsRequired for window.open() calls - specifically the Beam SuperStream OAuth hosted UI and the payslip print window
allow-downloadsRequired for file downloads. Payment files and payslip PDFs are downloaded via a blob URL assigned to a synthetic <a download> element; browsers block this in a sandboxed iframe without this token
caution

Do not add allow-top-navigation or allow-top-navigation-by-user-activation unless your integration specifically requires it, as these tokens allow the embedded app to redirect the parent frame.

UI Chrome Parameters

By default, when a ?code= parameter is present, both the side navigation and top app bar are hidden. You can override this behaviour:

ParameterValuesDefault (with ?code=)
showSideNavtrue / falsefalse
showAppBartrue / falsefalse

Example with navigation visible:

?code=<encrypted>&showSideNav=true&showAppBar=true

Step 4: Generate a Fresh Code Per Load

Embed codes should be generated on-demand, just before the iframe is rendered. Do not cache or reuse codes.

type Props = {
businessId: string | number;
route: string;
username: string;
};

// React example - generate a fresh code server-side before rendering
const PayrollEmbed = ({ businessId, route, username }: Props) => {
const [iframeSrc, setIframeSrc] = useState<string | null>(null);

useEffect(() => {
// Call your backend to generate a fresh code
fetch("/api/payroll-embed/code", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, businessId }),
})
.then((res) => res.json())
.then(({ code }) => {
const params = new URLSearchParams({ code });
setIframeSrc(
`https://acme.iopayroll.com/business/${businessId}/${route}?${params}`
);
});
}, [businessId, route, username]);

if (!iframeSrc) return <div>Loading...</div>;

return (
<iframe
src={iframeSrc}
style={{ width: "100%", height: "800px", border: "none" }}
title="Payroll"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads"
/>
);
};

Your backend endpoint should:

  1. Authenticate the request (verify the requesting user is authorised).
  2. Generate the encrypted code using the shared key.
  3. Return the code with a short TTL (e.g. 60 seconds).

Step 5: Programmatic Navigation (postMessage)

Once the embed auth flow has completed inside the iframe, you can navigate the embedded app to a new route from the parent frame using postMessage.

const navigateEmbed = (iframeEl: HTMLIFrameElement, route: string, targetOrigin: string): void => {
iframeEl.contentWindow?.postMessage(
{ action: "navigate", payload: { route } },
targetOrigin
);
};

// Example - navigate to pay runs using a ref
const iframeRef = useRef<HTMLIFrameElement>(null);

// Attach the ref to your iframe:
// <iframe ref={iframeRef} ... />

if (iframeRef.current) {
navigateEmbed(
iframeRef.current,
"/business/42/payruns",
"https://acme.iopayroll.com"
);
}

Supported Actions

ActionPayloadDescription
navigate{ route: string }Navigate to a new route within the embedded app. The route must start with /.

Timing

The embedded app registers the message event listener at mount, but will silently drop any postMessage sent before the embed code exchange has completed. The origin allowlist is populated as part of that exchange and is empty until it finishes.

Important: The embedded app does not currently emit a readiness signal to the parent frame. There is no event you can listen for to know when auth has completed. You must account for this in your integration, for example by delaying navigation commands until you are confident the iframe has had enough time to complete the auth flow, or by re-sending the command if the expected navigation does not occur.

Security

  • Allowed embed origins are returned by the Paysense API as part of the embed code exchange and stored in the iframe's sessionStorage. On every inbound postMessage, the embedded app reads those stored origins and validates event.origin against them. Any message from an origin not in that list is silently ignored.
  • Always pass the exact Paysense tenant origin as targetOrigin in postMessage. Never use "*".

Partner Configuration Checklist

Before going live, ensure the following is in place on your side.

Security

  • The AES-256-GCM encryption key is stored securely (environment variable, secrets manager, Azure Key Vault). Never store it in source code or client bundles.
  • Embed codes are generated server-side only.
  • All codes include an expiry of ≤ 60 seconds.
  • The endpoint that generates codes is authenticated. Only your application can call it, not end users directly.

Content Security Policy

If your application sets a Content-Security-Policy header, you must allow framing the Paysense origin:

frame-src https://<your-tenant>.iopayroll.com;

iframe sandbox Attribute

  • Ensure allow-scripts and allow-same-origin are always present, as the app cannot function without them.
  • The Paysense app uses sessionStorage in embed mode. Browsers that block third-party storage (e.g. Safari ITP) may require the user to explicitly permit storage access.
  • Test in Safari and Firefox private browsing modes before going live.

HTTPS

  • Your application must be served over HTTPS. Paysense enforces upgrade-insecure-requests in production and the frame-ancestors CSP directive will not match plain http:// origins.

Error Handling

The Paysense app renders an /unauthorized page when embed authentication fails.

CauseSymptomResolution
Expired code (expiry in the past)404 from /api/public/embed/codeGenerate a fresh code just before rendering the iframe
Wrong encryption key404 from /api/public/embed/codeVerify the key matches the tenant configuration
Tampered or malformed ciphertext404 from /api/public/embed/codeEnsure the Base64 output is not URL-encoded or truncated
Origin not in AllowedEmbedOriginsBrowser blocks iframe (CSP violation)Add the partner origin to Security:AllowedEmbedOrigins
X-Frame-Options blockingBrowser blocks iframeEnsure AllowedEmbedOrigins is set (suppresses the SAMEORIGIN header)
User does not exist in Keycloak401/404 from token exchangeProvision the user in Keycloak for the tenant
Third-party storage blockedApp cannot store tokensTest in browsers with strict cookie policies; consider Storage Access API

Security Considerations

  • Codes are one-time-use from a trust perspective: the same code can technically be replayed until its expiry. Always use short expiry values (≤ 60 seconds) and generate a new code for each iframe render.
  • AES-256-GCM provides authentication: tampered ciphertexts are rejected during decryption. You cannot forge a valid code without the key.
  • Keys are per-tenant: a code encrypted with tenant A's key cannot be accepted by tenant B.
  • postMessage is origin-validated: the embedded app accepts postMessage commands only from origins listed in Security:AllowedEmbedOrigins. Messages from unlisted origins are silently ignored. Always specify the exact tenant origin as targetOrigin when calling postMessage; never use "*".
  • Token storage is sessionStorage: embed tokens do not persist across browser sessions or leak to non-embed browser contexts.