Vendor guide · embedding
Embed payroll in your product. Your brand stays on the screen.
This guide is for platforms shipping Ledra Pay components to their customers: how authentication works, the two supported integration topologies, how to scope tokens to exactly what a page needs, and where the trust boundaries sit.
The model
Three credentials, one direction of trust
Everything hangs off one rule: the partner key never touches the browser. Your backend holds it; the browser only ever holds a short-lived, narrowly scoped embed token.
┌ your backend ──────────────────────────────┐ ┌ browser ─────────────────────────┐ lp_live_* partner key embed token (JWT, 15 min) │ │ │ POST /v1/tenants/{t}/embed/token │ <ledrapay-payslip-viewer> └─────────── mints ────────────────────────────┘ reads + scoped writes only
What an embed token can do: read within its pinned tenant, and perform the writes granted by its component allowlist. What it cannot do — enforced server-side, not by convention: mint further tokens (a leaked token dies at its TTL), touch partner admin or governance operations, cross tenants, write outside its component scope, or — if employee-pinned — see anyone else's :employeeId routes.
Integration
Pick a topology (both are first-class)
Same-origin proxy
Mount one reverse-proxy route on your own domain. The browser never makes a cross-origin call: no CORS, no preflights, your CSP stays tight, and token refresh rides your existing session.
Direct · api.ledrapay.com
Components call https://api.ledrapay.com/v1 straight from your pages. CORS is enabled, bearer-only. Requires origin-bound tokens — an unbound token is rejected from any origin that isn't Ledra's own sites.
Option A — proxy on your domain
// Express (any stack works — it's one route) const { createProxyMiddleware } = require('http-proxy-middleware'); app.use('/payroll-api', createProxyMiddleware({ target: 'https://api.ledrapay.com', changeOrigin: true, pathRewrite: { '^/payroll-api': '' }, })); # nginx equivalent location /payroll-api/ { proxy_pass https://api.ledrapay.com/; }
<!-- components point at YOUR domain — same-origin, no CORS anywhere --> <ledrapay-payslip-viewer tenant-id="ten_…" api-url="/payroll-api/v1"></ledrapay-payslip-viewer>
Option B — direct with an origin-bound token
// your backend: bind the token to the page's origin at mint time POST https://api.ledrapay.com/v1/tenants/{tenantId}/embed/token Authorization: Bearer lp_live_… { "components": ["payslip-viewer"], "origin": "https://app.yourdomain.com" }
<!-- components point at the Ledra API host --> <ledrapay-payslip-viewer tenant-id="ten_…" api-url="https://api.ledrapay.com/v1"></ledrapay-payslip-viewer>
https://app.yourdomain.com is rejected (403) when presented with any other Origin header — and writes without an Origin header at all are refused. Exfiltrating a token doesn't move it to another site. CORS responses never set Access-Control-Allow-Credentials; cookie auth does not exist on this API by design.Tokens
Mint narrowly, refresh silently
Mint server-side inside your session-authenticated routes, scoping each token to what the page actually renders:
| Field | What it does | When to use |
|---|---|---|
| components | Write allowlist, enforced server-side. A payslip-viewer token cannot approve a pay run; unknown names are a 400 with the valid list. ["*"] grants the union of component writes. | Always — list only what the page mounts. |
| employee_id | Pins the token to one employee: mismatched :employeeId routes return 404, tenant-level writes are denied. | Employee-facing pages (self-service payslips, own bank details, own leave requests). |
| origin | Binds the token to one browser origin. | Required for Option B; harmless defense-in-depth for Option A. |
// admin dashboard page → payroll-operations scope { "components": ["pay-runs-list", "pay-run-detail", "stp-submission"] } // employee self-service page → one employee, read + own-record writes { "components": ["payslip-viewer", "bank-details", "leave-request"], "employee_id": "emp_9f2c…" }
Handing the token to components
const el = document.querySelector('ledrapay-payslip-viewer'); el.embedToken = token; // JS property — never an HTML attribute (attributes serialize into the DOM) el.tokenProvider = () => // silent refresh: on a 401 the component re-calls this once and retries fetch('/payroll/embed-token').then(r => r.json()).then(b => b.token);
The tokenProvider round-trips through your backend, so token lifetime is effectively bounded by your user's session — sign out of your app, and the next refresh fails.
The bundle
Ship the SDK from your pipeline or ours
Two supported channels — full details in the CDN install guide and the npm install guide:
<!-- CDN: pin an exact version with SRI (recommended for direct script tags) --> <script type="module" crossorigin="anonymous" src="https://cdn.ledrapay.com/v1.4.1/ledrapay-components.js" integrity="sha384-…from the version's manifest.json…"></script> # npm: token-required private registry (enterprise access) # .npmrc @ledrapay:registry=https://npm.ledrapay.com/ //npm.ledrapay.com/:_authToken=${LEDRAPAY_NPM_TOKEN} npm i @ledrapay/components
/v1/ tracks the latest v1 release (5-minute cache, no SRI — the bytes change), exact versions like /v1.0.0/ are immutable (year-long cache, SRI documented). Only pin integrity to exact-version URLs. Enterprise channels (early builds, custom packs) are served from /enterprise/<channel>/ behind signed URLs or distribution tokens — see the CDN guide.
CSP for Option B: allow connect-src https://api.ledrapay.com (plus script-src https://cdn.ledrapay.com if you load from the CDN). Option A needs nothing beyond your own origin.
Trust boundaries
What embedding does — and doesn't — expose
Components are shadow-DOM web components, not iframes. They run in your page's origin: that's why theming, fonts, and event wiring feel native — and it means your page's JavaScript can read what a component renders. That's the correct trust model for a platform embedding payroll for its own customers: you already hold a partner key with full API access, so the embed grants your page nothing you didn't have.
jti.Go-live
Launch checklist
- Keys server-side only —
lp_live_*in your secret store; rotate via the partner API. - Mint per page, not per app — component allowlist matches what the page mounts;
employee_idon all self-service surfaces. - Origin-bind every token if you use Option B (and ideally on Option A too).
- Token as property + tokenProvider — never put tokens in HTML attributes, URLs, or storage.
- Pick your bundle strategy — pin an exact CDN version with SRI, or install
@ledrapay/componentsfrom the private registry. - Test the failure modes — expired token (component calls
tokenProvideronce, then shows its error state), scope violation (403), backend down (502JSON from the bridge).
Questions, or ready for live keys? Talk to us — design-partner slots include integration support.