Guides · Authentication
Authentication
Every API request authenticates with an API key. Cost-aware ingestion also signs the request with HMAC. This page covers all three: keys, the scopes that gate each endpoint, and how to sign an ingestion request.
API keys
Create a key in your dashboard (Developers → API Keys) and send it on every request in the Authorization header as a bearer token.
curl https://api.bearlumen.com/v1/ping \
-H "Authorization: Bearer sk_test_YOUR_API_KEY"
Keep your keys secret
A key grants access to your organization's data. Never commit one to version control or ship it in client-side code. Load it from an environment variable in production, and rotate any key that may have been exposed.
CLI and MCP
The CLI and the MCP server read your data and never write it, so they need a different key from the one your application sends events with. When you create a key for them, select the CLI and MCP (read-only) preset. It grants exactly costs:read, revenue:read, usage:read, and dimensions:read, and nothing else. bearlumen login and an MCP client's OAuth sign-in both create a key with this preset for you (see Signing in with OAuth). The other preset, SDK ingestion, grants usage:write alone: that key can send events and cannot read anything back, so a CLI pointed at it fails bearlumen ping and names the preset to select instead.
Key expiry and rotation
A key can carry an expiry (expires_at). Keys you create by hand have none unless you set one; keys issued by bearlumen login expire after 90 days, and keys issued to an MCP client after 30 days. An expired key authenticates exactly like a revoked one: 401 on every request. GET /v1/ping returns the key's expires_at (or null), so a script can check how long it has left.
Rotate a key from the API Keys page or with POST /v1/api-keys/{id}/rotate (dashboard session, not API key, authentication). Rotation creates a new key with the same name (suffixed (Rotated)), scopes, environment, and description, returns the new plaintext once, and revokes the old key in the same operation. The expiry is not copied: the new key starts with a fresh lifetime. Because the old key stops working immediately, deploy the new value before you rotate, or accept a short window of 401s. A revoked or expired key cannot be rotated; create a new one.
{
"apiKey": "sk_live_...",
"keyInfo": {
"id": "7b1c...",
"name": "Production ingestion (Rotated)",
"scopes": ["usage:write"],
"environment": "production",
"createdAt": "2026-09-07T10:12:00.000Z",
"createdBy": "usr_..."
},
"warning": "This API key will not be shown again. Please save it in a secure location."
}
Test and live keys
A key created in the test environment starts with sk_test_; every other key starts with sk_live_. The prefix is the only visible difference, and the server, not the client, decides what it means: every event a test key sends is stored with livemode: false, and read endpoints, dashboards, and cost alerts only ever see livemode: true data. Use a test key from local development and CI so rehearsal traffic never lands in your real cost and margin numbers. Test keys can be revealed again from the dashboard; live keys are shown once at creation and stored as a hash forever. Keys are 48 characters, and the last six are a checksum, so a secret scanner can recognise a real Bear Lumen key without calling the API.
Scopes
Each key carries scopes that limit what it can do. An endpoint returns 403 if your key is missing the scope it requires, so grant a key only the scopes it needs.
| Scope | Grants | Used by |
|---|---|---|
costs:read | Read cost summaries, breakdowns, trends, attribution, and per-end-user costs. | Attribution Data, Cost Data, Dimension Data, End User Costs, Insights, Rate Cards |
dimensions:read | Read custom dimension definitions. | Dimension Data |
revenue:read | Read margin and profitability analysis. | Insights, Margin Data |
usage:read | Query recorded usage events and aggregations. | Usage Tracking |
usage:write | Send usage events (POST /usage-events and /usage-events/batch). | Usage Events |
Signing ingestion requests (HMAC)
The usage-ingestion endpoints (POST /usage-events and /usage-events/batch) require an HMAC-SHA256 signature in addition to your API key. Read endpoints do not.
Using an SDK? This is automatic
The Node.js and Python SDKs sign every ingestion request for you. You only need this recipe when you call the ingestion API directly over HTTP.
- Set
X-Timestampto the current Unix time in seconds. - Build the payload string
{timestamp}.{body}, wherebodyis the raw JSON request body exactly as sent. - Compute HMAC-SHA256 of that payload keyed with your API key, hex-encoded.
- Send
X-Signature: v1={hex digest}.
API_KEY="sk_test_YOUR_API_KEY"
TS=$(date +%s)
BODY='{"idempotency_key":"3f9a2b1c-8d7e-4f6a-9b2c-1a2b3c4d5e6f","model":"gpt-4o","input_tokens":1500,"output_tokens":420,"event_invoked_at":"2026-01-15T10:30:00.000Z","provider":"openai"}'
# HMAC-SHA256 over "{timestamp}.{raw body}", keyed with your API key, hex-encoded.
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$API_KEY" -r | cut -d' ' -f1)
curl -X POST https://api.bearlumen.com/v1/usage-events \
-H "Authorization: Bearer $API_KEY" \
-H "X-Timestamp: $TS" \
-H "X-Signature: v1=$SIG" \
-H "Content-Type: application/json" \
-d "$BODY"
Two details that trip people up
Sign the raw body bytes exactly as sent. If you re-serialize the JSON after signing (different key order or whitespace), the signature will not match.
The X-Timestamp must be current: requests whose timestamp is more than 5 minutes from server time are rejected with a 401. This is replay protection, not a limit on how old your event_invoked_at data can be (backdated events are fine).
Request headers
| Header | Value |
|---|---|
Authorization | Bearer YOUR_API_KEY |
X-Timestamp | Current Unix time in seconds |
X-Signature | v1={hex HMAC-SHA256 of "{timestamp}.{body}"} |
Content-Type | application/json |
Signing in with OAuth
Bear Lumen is an OAuth 2.1 authorization server, and a token it issues is an ordinary API key: it shows up on the API Keys page with the client that obtained it, it carries the scopes you approved, and revoking it there works like revoking any other key. Two grants are supported, one per kind of client.
The CLI: device grant
bearlumen login uses the device authorization grant (RFC 8628), which exists for programs that have no browser of their own. The CLI asks the server for a short user code, shows it to you, opens https://app.bearlumen.com/activate with the code prefilled, and polls for the result while you approve it in the dashboard. Approval creates a live key named after your machine with the four query scopes, valid for 90 days.
$ bearlumen login
Opening https://app.bearlumen.com/activate?user_code=BCDF-GHJK
Waiting for approval...
Signed in to acme-inc as "CLI on blaise-mbp" (live).
Codes are eight letters shown as XXXX-XXXX, drawn from an alphabet with no vowels and no look-alike glyphs, and are matched case-insensitively with the hyphen ignored. A code expires after 15 minutes. The CLI polls every 5 seconds and slows down when told to; the responses it sees while waiting are the standard RFC 6749 error shape:
{ "error": "authorization_pending", "error_description": "The user has not yet approved the request." }
MCP clients: authorization code with PKCE
A remote MCP client (Claude Desktop, Claude Code, or any client that implements the MCP authorization spec) uses the authorization code grant. It discovers everything from the server's metadata, so you only ever give it the URL https://mcp.bearlumen.com/mcp:
GET https://api.bearlumen.com/.well-known/oauth-authorization-server
{
"issuer": "https://api.bearlumen.com",
"authorization_endpoint": "https://app.bearlumen.com/oauth/authorize",
"token_endpoint": "https://api.bearlumen.com/v1/oauth/token",
"device_authorization_endpoint": "https://api.bearlumen.com/v1/oauth/device_authorization",
"registration_endpoint": "https://api.bearlumen.com/v1/oauth/register",
"revocation_endpoint": "https://api.bearlumen.com/v1/oauth/revoke",
"code_challenge_methods_supported": ["S256"],
"grant_types_supported": ["authorization_code", "urn:ietf:params:oauth:grant-type:device_code"],
"token_endpoint_auth_methods_supported": ["none"]
}
The client registers itself (RFC 7591, public clients only, no secret), sends you to https://app.bearlumen.com/oauth/authorize where you review the client name and scopes and approve, then exchanges the code at the token endpoint. PKCE with S256 is required on every request; plain is rejected. Redirect URIs must be https:// or a loopback address and are matched exactly. The resulting key expires after 30 days, and the client re-runs the flow when it gets a 401. There are no refresh tokens yet.
Where the key ends up
Whichever grant issued it, the key is listed on your API Keys page with a badge naming the client (CLI for a device login, otherwise the client's registered name). Revoke it there, or with POST /v1/oauth/revoke and the token as its own bearer credential, which is what bearlumen logout does.
Account security
API keys authenticate programs. Your dashboard sign-in authenticates you, and two features under Settings, Security harden it.
Multi-factor authentication
Enrol an authenticator app (any TOTP app: 1Password, Google Authenticator, Authy) from Settings, Security. After enrolment, signing in with your password or with Google asks for a six-digit code before a session is issued. You receive ten single-use recovery codes when you confirm enrolment; store them somewhere other than the device that holds the authenticator, because each one signs you in exactly once when the app is unavailable, and you can regenerate the set from the same page. Five wrong codes in a row void the sign-in attempt, so start over from the password step rather than retrying.
Active sessions
The same page lists every device signed in to your account: when the session began, when it was last active, the browser, and the IP address, with the current one marked. Sign out any single session, or every session except this one, which is the first thing to do if a laptop goes missing. A signed-out session cannot refresh itself and lands on the sign-in page on its next request.