Authentication: Client Credentials to Access Token
Every Paprel API call is authenticated with a short-lived access token — a JWT sent as a Bearer header. You never send your long-lived secret on API requests; you exchange it for a token and send that instead.
This page is the one-time setup every guide in this section depends on. It takes about five minutes.
The model in one paragraph
Your workspace issues client credentials: a client_id and a client_secret. Those identify your application, not a user. You exchange them for an access token using the OAuth 2.0 client credentials flow — the token is short-lived and tenant-scoped, so a leaked token has a small blast radius and a clear expiry, while your secret stays on your server and is never attached to API traffic. The same model serves REST, MCP, and machine-to-machine clients.
client_id + client_secret access token (JWT, short-lived)
│ │
└────── token exchange ─────────────┘
│
authorization: Bearer <token>
│
every API request
Step 1 — Create credentials in your workspace
Sign in to your sandbox workspace and create an API credential. The workspace shows the client_id and lets you copy the client_secret once — store it in your secret manager, not in code.

The credentials list in the workspace: managed, revocable, per-application access — created here, never hardcoded.
Treat the pair like a password: one credential per application or environment, rotate on personnel changes, revoke without ceremony when in doubt. (For when credentials are the right model versus user-consent OAuth flows, see API Credentials vs OAuth 2.0.)
Step 2 — Exchange for an access token
Exchange the pair for a token using the standard client credentials grant. The token endpoint and exact request shape are in the API documentation alongside your workspace credentials — the response gives you a JWT and its expiry.
# OAuth 2.0 client credentials grant — endpoint in the API docs
export ACCESS_TOKEN="<access_token from the exchange response>"
export BASE="https://api-sandbox.paprel.com"
Cache the token until shortly before expiry and re-exchange; don't request a fresh token per call.
Step 3 — Call the API
curl $BASE/v1/accounting/journals \
-H "authorization: Bearer $ACCESS_TOKEN"
For reads, that's the whole runtime model: one header. Tokens are tenant-scoped with per-route grants, so the token only reaches what its role allows — the same scoping that governs MCP access for AI agents and human roles. For how scopes and permissions are designed, read OAuth Scopes and Permissions for Embedded Accounting Platforms.
Writes carry one more thing — a request signature.
Request signing on writes
Every request that creates or changes accounting state carries one more header beyond the bearer token: an x-data-signature. It is required on write endpoints.
The bearer token authenticates the caller. The signature is an integrity check on the request body — it travels with the request so the server can confirm the payload arrived exactly as it was sent, and it carries a short freshness window so a captured request can't be re-sent indefinitely. Authentication is the token's job; the signature is tamper-evidence on top of it.
The signature is computed over the request body and is therefore per-request — there is no static value you set once and reuse.
curl -X POST $BASE/v1/accounting/journals \
-H "authorization: Bearer $ACCESS_TOKEN" \
-H "x-data-signature: <signature of this request's body>" \
-H "content-type: application/json" \
-d '{ … }'
Do not hand-roll the signature. The exact construction — what is canonicalized, the encoding — is defined by the SDK and the API reference, and the SDK computes the header for you on every write. The raw curl examples in the other guides show the auth header only and omit the signature for readability; for real integration, let the SDK compute it rather than reproducing the algorithm by hand.
Keep your secrets off the frontend
Your client_secret is what authenticates you, and it is a real secret: anything that holds it can mint tokens as you. A browser bundle or a mobile app cannot keep a secret — it ships to the user's device, where it can be unpacked and read. So the rule is absolute:
The client_secret never appears in frontend code, mobile binaries, or any config delivered to a client. If it reaches the device, treat it as already public and rotate it. The same goes for any other API secret your integration holds.
That makes the architecture straightforward — writes are server-to-server:
Browser / mobile app
│ (your own session — never a Paprel secret)
▼
Your backend ──── holds the client_secret
│ mints the token, calls Paprel
▼
Paprel API
Your frontend talks to your backend. Your backend is the only thing that holds Paprel secrets, exchanges them for a token, and calls Paprel to write.
For the case people usually ask about — rendering a report or a ledger view directly in your customer's browser — don't ship a secret to do it. Mint a short-lived, read-scoped access token on your server and hand that to the client. It expires on its own and only reaches what its scope allows, so a leaked read token has a small blast radius and a clear expiry. (Reads don't require a signature, so a read token is all the browser needs.)
Failure modes worth knowing
- 401 with a valid-looking token — it expired; exchange again. Build the refresh into your client rather than retrying the same token.
- Write rejected, read with the same token works — almost always a missing or malformed
x-data-signature. Reads don't need it; writes do. Sign through the SDK. - Token works in sandbox, fails in production — credentials are per-environment; production has its own pair. Same wire format, different secrets.
- Secret committed to a repo — revoke it in the workspace immediately and issue a new credential; rotation is designed to be boring.
Where to next
- Build Embedded Accounting in 20 Minutes — uses exactly this setup
- API reference — every endpoint, with the auth scheme declared per route
- Roles, permissions, and audit controls — what governs which token reaches what