Build the embed token backend (BFF)
A backend-for-frontend (BFF) is a small route on your server that sits between your browser app and Paprel. For embedded UI, the BFF has one job: exchange App Connect credentials for a short-lived access token — nothing else.
Embedded UI uses App Connect M2M (client_credentials). The client secret must never ship to the browser. Your backend exposes one small route the frontend calls; that route exchanges credentials for a short-lived access token and returns it to your app.
Everything else — COA, journals, banking widgets — is @paprel/embed-accounting calling Paprel with that token. You are not reimplementing accounting APIs in the BFF.
Browser → YOUR /api/embed-token (no secret)
Your backend (BFF) → Paprel POST /v1/app-connect/oauth/token (client_id + client_secret)
Browser → Paprel GET/POST /v1/... (Bearer token via @paprel/embed-core)
If you have not created App Connect credentials yet, follow Create sandbox credentials first. For the distinction between App Connect and API credentials, see Authentication. For mount steps after the backend route works, see Get started.
Minimum contract
Your frontend calls a route you own (convention: GET /api/embed-token). It must run only for authenticated partner users — session cookie, JWT, or your own API key gate. Do not expose it publicly without your auth check.
Success response (200)
type EmbedTokenResponse = {
accessToken: string;
expiresAt: number; // Unix epoch MILLISECONDS
permissions?: string[];
companyId?: string;
expiresIn?: number; // seconds, optional convenience
};
Error response
{ "error": "Token exchange failed" }
Never return client_secret. Never log full access tokens in production.
Server environment (secrets only)
| Variable | Example | Notes |
|---|---|---|
PAPREL_API_BASE_URL | https://api.paprel.com | Environment API base (sandbox: https://api-sandbox.paprel.com) |
APP_CONNECT_TOKEN_URL | {base}/v1/app-connect/oauth/token | Copy exactly from App Connect client detail in your workspace |
APP_CONNECT_CLIENT_ID | PLC_… | |
APP_CONNECT_CLIENT_SECRET | PLS_… | Secrets manager in prod — never NEXT_PUBLIC_* or Vite VITE_* |
PARTNER_DOMAIN | app.yourproduct.com | Sent as x-partner-domain on token exchange and API calls |
Token exchange (inside your backend route)
POST {APP_CONNECT_TOKEN_URL}
Content-Type: application/json
x-partner-domain: {PARTNER_DOMAIN}
{
"grant_type": "client_credentials",
"client_id": "...",
"client_secret": "..."
}
Paprel wraps responses in { "data": { … } }. Map fields like this:
const data = raw.data ?? raw;
const expiresIn = Number(data.expires_in ?? 3600);
return {
accessToken: data.access_token,
expiresAt: Date.now() + expiresIn * 1000,
permissions: data.permissions,
companyId: data.company_id,
expiresIn,
};
Optional: pass "scope": "accounting:account-list accounting:journal-list" (space-separated subset of scopes configured on the App Connect client).
Express example
app.get("/api/embed-token", requireSession, async (req, res) => {
const tokenRes = await fetch(process.env.APP_CONNECT_TOKEN_URL!, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-partner-domain": process.env.PARTNER_DOMAIN!,
},
body: JSON.stringify({
grant_type: "client_credentials",
client_id: process.env.APP_CONNECT_CLIENT_ID,
client_secret: process.env.APP_CONNECT_CLIENT_SECRET,
}),
});
const raw = await tokenRes.json();
const data = raw.data ?? raw;
if (!tokenRes.ok || !data.access_token) {
return res.status(502).json({ error: "Token exchange failed" });
}
const expiresIn = Number(data.expires_in ?? 3600);
res.json({
accessToken: data.access_token,
expiresAt: Date.now() + expiresIn * 1000,
permissions: data.permissions,
companyId: data.company_id,
expiresIn,
});
});
Replace requireSession with your app's session middleware.
Next.js App Router example
// app/api/embed-token/route.ts
import { NextResponse } from "next/server";
import { getServerSession } from "..."; // your auth
export async function GET() {
const session = await getServerSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const tokenRes = await fetch(process.env.APP_CONNECT_TOKEN_URL!, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-partner-domain": process.env.PARTNER_DOMAIN!,
},
body: JSON.stringify({
grant_type: "client_credentials",
client_id: process.env.APP_CONNECT_CLIENT_ID,
client_secret: process.env.APP_CONNECT_CLIENT_SECRET,
}),
});
const raw = await tokenRes.json();
const data = raw.data ?? raw;
if (!tokenRes.ok || !data.access_token) {
return NextResponse.json({ error: "Token exchange failed" }, { status: 502 });
}
const expiresIn = Number(data.expires_in ?? 3600);
return NextResponse.json({
accessToken: data.access_token,
expiresAt: Date.now() + expiresIn * 1000,
permissions: data.permissions,
companyId: data.company_id,
expiresIn,
});
}
Wire the frontend
configureAccounting maps your BFF response into the SDK:
auth: {
partnerDomain: "app.yourproduct.com",
async getTokens() {
const res = await fetch("/api/embed-token", { credentials: "include" });
if (!res.ok) throw new Error("Embed token failed");
const body = await res.json();
return {
accessToken: body.accessToken,
expiresAt: body.expiresAt,
permissions: body.permissions,
companyId: body.companyId,
};
},
},
Full boot sequence: Get started.
CORS and baseUrl
| Topology | SDK baseUrl | Partner work |
|---|---|---|
| Same-origin gateway (recommended) | "" | Reverse-proxy /v1 → Paprel API; BFF at /api/embed-token on same origin |
| Direct to Paprel API | https://api.paprel.com | Paprel CORS must allow your origin |
| Local dev | "" | Vite/webpack proxy /v1 + dev BFF (see Sample app) |
Most production partners use one domain for app + BFF + /v1 proxy — no browser CORS pain.
App Connect writes from embed
Embed POST/PUT calls use App Connect JWTs and do not require x-data-signature (operator browser sessions still do). Your BFF only mints tokens — it does not sign request bodies.
Accounting GETs from the SDK: do not add company_id query params. The access token is already company-bound; the gateway injects tenant context from the JWT.
Security checklist
-
client_secretonly in server env / secrets manager - BFF route requires partner user session before minting a Paprel token
- HTTPS everywhere in production
- Token TTL defaults to 4 hours; SDK refreshes via
getTokens()before expiry - Unauthenticated requests to the token route return
401
Verify before wiring widgets
- Logged-in user:
GET /api/embed-tokenreturnsaccessToken+expiresAt - Logged-out user: token route returns
401 - Network tab shows
Authorization: Beareron/v1/accounting/...after widgets load
Run the Sample partner app locally to compare behavior against your implementation. The Express and Next.js examples on this page are the reference BFF — same contract as the sample.
Generate with AI (Cursor, Claude, Codex)
You can scaffold the BFF and frontend wiring in one pass. Give your coding agent:
- Paprel OpenAPI embed slice — after
npm install @paprel/embed-accounting, attachnode_modules/@paprel/embed-accounting/openapi/openapi-embed-v1.json. The full gateway spec is also downloadable from API documentation. - This page — BFF contract and security rules
- Get started —
configureAccountingpattern - Your stack — e.g. Next.js 15 App Router + Auth0, widgets:
paprel-journal-list,paprel-chart-of-accounts
Prompt template:
Implement a Paprel embed integration for my {framework} app.
Backend: GET /api/embed-token per https://paprel.com/documentation/embedded-ui/build-bff
- Require my existing user session before token exchange
- Exchange client_credentials at APP_CONNECT_TOKEN_URL
- Return { accessToken, expiresAt, permissions, companyId }
Frontend: configureAccounting + mount {widget list}
- auth.getTokens() calls /api/embed-token
- Import the required domain packages to register their custom elements
Attach: openapi-embed-v1.json (from @paprel/embed-accounting), my .env.example constraints.
Never put client_secret in frontend env vars.
The Express and Next.js sections above are sufficient stack-specific starters. Extend the prompt template with your framework, auth provider, and widget list.
What not to put in the backend route
- Accounting business logic or validation
- Proxying every
/v1/*call (optional; not required for v1) - Returning
client_secretor long-lived refresh tokens to the browser - Caching tokens in shared Redis without binding to the partner user session
Related
- Embedded UI overview — architecture and v1 scope
- Get started — install and mount widgets
- Post your first journal in 5 minutes — create App Connect credentials and verify the exchange
- Authentication — compare credential paths and write authentication
- OAuth scopes — scope design for widgets