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.

Authbearer embed tokens · 15-min TTL · no cookies, ever
Topologiessame-origin proxy · direct CORS
Scopingtenant-pinned · component allowlist · employee · origin
Your keyserver-side only — never in the browser

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)

Option A · recommended default

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.

Option B · lowest friction

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>
Why origin binding is mandatory here: a token minted for 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:

FieldWhat it doesWhen to use
componentsWrite 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_idPins 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).
originBinds 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.

The boundary that matters is per-token, not per-page. Scope tokens to the page (components + employee), and the blast radius of any single leaked token is: one tenant, one component set, fifteen minutes, and — with origin binding — one origin. Every write it performs still lands in the evidence chain with the token's jti.

Go-live

Launch checklist

  1. Keys server-side onlylp_live_* in your secret store; rotate via the partner API.
  2. Mint per page, not per app — component allowlist matches what the page mounts; employee_id on all self-service surfaces.
  3. Origin-bind every token if you use Option B (and ideally on Option A too).
  4. Token as property + tokenProvider — never put tokens in HTML attributes, URLs, or storage.
  5. Pick your bundle strategy — pin an exact CDN version with SRI, or install @ledrapay/components from the private registry.
  6. Test the failure modes — expired token (component calls tokenProvider once, then shows its error state), scope violation (403), backend down (502 JSON from the bridge).

Questions, or ready for live keys? Talk to us — design-partner slots include integration support.