Embedded UI 0.1.0-beta

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.

Text
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)

TypeScript
type EmbedTokenResponse = {
  accessToken: string;
  expiresAt: number; // Unix epoch MILLISECONDS
  permissions?: string[];
  companyId?: string;
  expiresIn?: number; // seconds, optional convenience
};

Error response

JSON
{ "error": "Token exchange failed" }

Never return client_secret. Never log full access tokens in production.

Server environment (secrets only)

VariableExampleNotes
PAPREL_API_BASE_URLhttps://api.paprel.comEnvironment API base (sandbox: https://api-sandbox.paprel.com)
APP_CONNECT_TOKEN_URL{base}/v1/app-connect/oauth/tokenCopy exactly from App Connect client detail in your workspace
APP_CONNECT_CLIENT_IDPLC_…
APP_CONNECT_CLIENT_SECRETPLS_…Secrets manager in prod — never NEXT_PUBLIC_* or Vite VITE_*
PARTNER_DOMAINapp.yourproduct.comSent as x-partner-domain on token exchange and API calls

Token exchange (inside your backend route)

http
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:

TypeScript
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

TypeScript
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

TypeScript
// 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:

TypeScript
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

TopologySDK baseUrlPartner work
Same-origin gateway (recommended)""Reverse-proxy /v1 → Paprel API; BFF at /api/embed-token on same origin
Direct to Paprel APIhttps://api.paprel.comPaprel 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_secret only 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

  1. Logged-in user: GET /api/embed-token returns accessToken + expiresAt
  2. Logged-out user: token route returns 401
  3. Network tab shows Authorization: Bearer on /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:

  1. Paprel OpenAPI embed slice — after npm install @paprel/embed-accounting, attach node_modules/@paprel/embed-accounting/openapi/openapi-embed-v1.json. The full gateway spec is also downloadable from API documentation.
  2. This page — BFF contract and security rules
  3. Get startedconfigureAccounting pattern
  4. Your stack — e.g. Next.js 15 App Router + Auth0, widgets: paprel-journal-list, paprel-chart-of-accounts

Prompt template:

Text
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_secret or long-lived refresh tokens to the browser
  • Caching tokens in shared Redis without binding to the partner user session
Evaluate Paprel

Build in sandbox, launch with a production trial

Use sandbox for developer testing with no billing. When you are ready for real workflows, start production on a monthly plan with a 14-day free trial.

API-First Delivery
Audit-Ready Controls
Sandbox And Guided Rollout
We use cookies to improve your experience.