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.5.0/ledrapay-components.js" integrity="sha384-O6YoudbBpk3bacQXUeivmaJ2IRWnrN7DHlsv5ISv0FELm6k36qfciUNZwnzAoRwu"></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.5.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.Live keys
What changes when you go live
Sandbox is deliberately permissive — it exists to be easy, and none of the rules below apply there. The moment you swap in a live key, three requirements switch on. They are the difference between a payroll API and a toy, and each one fails loudly rather than silently:
| Rule | What you'll see if you miss it | Why |
|---|---|---|
| Idempotency-Key | 400 bad_request — "Idempotency-Key header is required on live write requests" | A network retry must never double-pay a workforce. With a key, a replay returns the original response instead of running the write twice. |
| origin | 400 when origin is absent from the mint body of /embed/token or /users/token | An unbound token is replayable from anywhere it leaks. The origin pins the blast radius to your app. |
| engine: mock | 400 — a live key cannot bind a tenant to the mock engine | Mock computes invented numbers. Safe for sandbox, catastrophic behind real payroll. |
Idempotency in practice
Send an Idempotency-Key on every money-moving POST from a live key — pay runs, employees, leave, adjustments, submissions, super, payment files. Generate it once, per logical operation, before the first attempt, and reuse the same value for every retry of that operation:
POST https://api.ledrapay.com/v1/tenants/{tenantId}/pay-runs
Authorization: Bearer lp_live_…
Idempotency-Key: "a7f3c1e0-…" // one UUID per operation, stable across retries
// same key + same body → the original response, replayed. The pay run runs once.
// same key + different body → 409. You reused a key for a different operation.
Key lifecycle
Rotating and revoking
A live key is shown to you once, at issue. We store only a SHA-256 hash of it, so we cannot recover or re-display it later — a lost key is replaced, never retrieved. Put it in your secret store the moment you receive it.
- Rotation never leaves you dark. The replacement key is minted before the old one is revoked, so there is always a working credential — deploy the new key, confirm traffic, then the old one goes.
- Revoking a key cascades to its tokens. Every short-lived embed and user token that key minted dies immediately, rather than staying valid for the remainder of its ≤15-minute TTL. Revocation is now a hard stop, not a countdown.
- A single session can be revoked on its own — one compromised page or one suspect user, without disturbing the key or anyone else's session.
- Rotate on any suspicion. Treat a key that has landed in a repository, a page, a log line, a support ticket or a screenshot as exposed. Rotation is cheap; a shared payroll credential is not.
Go-live
Launch checklist
- Keys server-side only —
lp_live_*in your secret store, captured at issue (we can't re-display it); rotate on any suspected exposure. - Mint per page, not per app — component allowlist matches what the page mounts;
employee_idon all self-service surfaces. - Origin-bind every token — required on live keys for both
/embed/tokenand/users/token, whichever topology you chose. - Send an
Idempotency-Keyon every money-moving write — one stable key per operation, reused across retries, or the live API returns400. - Confirm the tenant is on a real engine — a live key cannot bind a tenant to
mock; catch that in staging, not on your first pay run. - 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.