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 covers both Paprel machine-to-machine credential paths. Choose the path that matches your integration before creating credentials.
Choose the credential path
| If you are building… | Use | Start here |
|---|---|---|
| Embedded UI, a partner-hosted accounting experience, or the current curl quickstarts | App Connect | Create an App Connect client and exchange a test token |
| A server-owned operator integration using API credentials | API credentials | Continue with Steps 1–3 below |
App Connect credentials include a configured partner domain. App Connect API calls send that domain in x-partner-domain; writes do not use x-data-signature. The embed token backend guide shows how to keep the App Connect secret on your server while returning a short-lived token to the browser.
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
API credentials: Step 1 — Create credentials
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.)
API credentials: 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.
API credentials: 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.
For writes, the required headers depend on how the access token was issued.
Request signing on writes
Paprel supports two write-authentication paths:
- App Connect access tokens: send the bearer token with
x-partner-domain. These writes do not requirex-data-signature, whether you call the API through@paprel/embed-*packages or directly over HTTP. - Operator or API credential tokens: include
x-data-signatureonPOST,PUT, andPATCHrequests with a JSON body. Reads (GET) do not need it.
The rest of this section applies only to operator and API credential tokens.
The bearer token authenticates the caller. The signature is an integrity check on the exact request body — it travels with the request so the server can confirm the payload arrived as sent, and it carries a five-minute freshness window so a captured request cannot be replayed later. Authentication is the token's job; the signature is tamper-evidence on top of it.
There is no static value you set once. Sign the bytes you are about to send, on every write.
The API reference labels this header “AES-encrypted.” That label is leftover from the spec. The construction below is HMAC-SHA256. Match this page, not the OpenAPI description.
How to generate x-data-signature
Decode the access token JWT (not the client secret). You need two payload claims:
| Claim | Role |
|---|---|
sub | Tenant-scoped subject. Already inside the token. |
jti | Token id. Already inside the token. |
Then, for each write:
timestamp— Unix time in milliseconds, as a decimal string (Date.now().toString()).nonce— a unique string without|. A UUIDv4 is fine.key— hex-encoded SHA-256 of{sub}:{jti}:{timestamp}.signature— hex-encoded HMAC-SHA256 of the exact HTTP body bytes, keyed withkeyas a UTF-8 string (the 64 hex characters, not the raw 32-byte hash).- Header value — Base64 of
{timestamp}|{nonce}|{signature}.
x-data-signature = Base64( timestamp + "|" + nonce + "|" + HMAC_HEX )
HMAC_HEX = HMAC-SHA256( key = SHA256_HEX(sub + ":" + jti + ":" + timestamp), body )
Sign the same bytes the HTTP client will send. Pretty-printed JSON, reordered keys, or a trailing newline will fail verification even if the object is equivalent.
The timestamp must be within five minutes of server time. Clock skew beyond that returns 412.
@paprel/sdk computes this header on every write. Use that in application code. The snippets below are for curl, generated clients, and languages the SDK does not cover yet.
Node.js
import { createHash, createHmac, randomUUID } from "node:crypto";
export function xDataSignature(accessToken, body) {
const payload = JSON.parse(
Buffer.from(accessToken.split(".")[1], "base64url").toString("utf8"),
);
const timestamp = Date.now().toString();
const nonce = randomUUID();
const key = createHash("sha256")
.update(`${payload.sub}:${payload.jti}:${timestamp}`)
.digest("hex");
const signature = createHmac("sha256", key).update(body, "utf8").digest("hex");
return Buffer.from(`${timestamp}|${nonce}|${signature}`).toString("base64");
}
export BODY='{"currency":"USD","date":"2026-08-19","description":"First journal","lines":[{"account_code":"1010","debit":"100.00"},{"account_code":"4000","credit":"100.00"}],"posted":true}'
export SIGNATURE="$(node --input-type=module -e '
import { createHash, createHmac, randomUUID } from "node:crypto";
const payload = JSON.parse(Buffer.from(process.env.ACCESS_TOKEN.split(".")[1], "base64url").toString("utf8"));
const timestamp = Date.now().toString();
const nonce = randomUUID();
const key = createHash("sha256").update(`${payload.sub}:${payload.jti}:${timestamp}`).digest("hex");
const signature = createHmac("sha256", key).update(process.env.BODY, "utf8").digest("hex");
process.stdout.write(Buffer.from(`${timestamp}|${nonce}|${signature}`).toString("base64"));
')"
curl -X POST "$BASE/v1/accounting/journals" \
-H "authorization: Bearer $ACCESS_TOKEN" \
-H "x-data-signature: $SIGNATURE" \
-H "content-type: application/json" \
--data-raw "$BODY"
Python
import base64, hashlib, hmac, json, time, uuid
def x_data_signature(access_token: str, body: str) -> str:
part = access_token.split(".")[1]
part += "=" * (-len(part) % 4)
payload = json.loads(base64.urlsafe_b64decode(part))
timestamp = str(int(time.time() * 1000))
nonce = str(uuid.uuid4())
key = hashlib.sha256(
f"{payload['sub']}:{payload['jti']}:{timestamp}".encode()
).hexdigest()
signature = hmac.new(key.encode(), body.encode(), hashlib.sha256).hexdigest()
return base64.b64encode(f"{timestamp}|{nonce}|{signature}".encode()).decode()
Test vector
Use this to check an implementation before you hit the API. Fixed inputs, one expected header:
sub = company:user
jti = token-id
timestamp = 1700000000000
nonce = 00000000-0000-4000-8000-000000000000
body = {"ok":true}
Expected x-data-signature:
MTcwMDAwMDAwMDAwMHwwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDB8NTAwYTY0Mjg3OTU0ZWFjYmI2MTIzYWFmODUxZDAwMTQ1NWZjZDk0NWEyZGVlMDk4NTI4OTFmNjlmNTcwNmRlZA==
The signing examples above apply only to operator and API credential writes. App Connect guides intentionally omit x-data-signature; those calls send x-partner-domain instead.
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.
For API credentials, 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
With API credentials, your frontend talks to your backend, and your backend calls Paprel. With App Connect, the BFF still holds and exchanges the secret, but it may return a short-lived, domain-bound token that Embedded UI uses to call Paprel directly.
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.
- API-credential write rejected with
412, while a read works —x-data-signatureis missing, malformed, or outside its five-minute timestamp window. This does not apply to App Connect writes; verifyx-partner-domainfor that path instead. - 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
- Post your first journal in 5 minutes — create App Connect credentials and verify the token exchange
- Build Embedded Accounting in 20 Minutes — continue the App Connect accounting flow
- API reference — every endpoint, with the auth scheme declared per route
- Roles, permissions, and audit controls — what governs which token reaches what