Authentication
The Brane API supports two authentication strategies. Choose based on your use case:
| Strategy | Best for | Header format |
|---|---|---|
| JWT Bearer | User sessions, interactive apps | Authorization: Bearer <accessToken> |
| API Key | Scripts, integrations, AI agents | X-Api-Key: <key> |
incrm is the platform's technical name: it stays in the API's address (api.incrm.app), the API keys (incrm_, incrm_pub_), the webhook headers (X-InCRM-*) and the npm packages (@incrm-app/sdk, @incrm-app/mcp-server).
JWT Bearer Authentication
Sign In
Obtain an access token by posting credentials:
curl -X POST https://api.incrm.app/api/v1/auth/sign-in \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com", "password": "yourPassword123"}'
Response:
{
"success": true,
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiIs..."
}
}
The response also sets a refreshToken as an httpOnly cookie. This cookie is used exclusively for token refresh — it is never returned in the JSON body.
Sign in with Google
POST /api/v1/auth/google exchanges a Google ID token for the same token pair as password sign-in. Obtain the credential in the browser with Google Identity Services, then post it:
curl -X POST https://api.incrm.app/api/v1/auth/google \
-H "Content-Type: application/json" \
-d '{"credential": "<Google ID token>"}'
The response body and the refreshToken cookie are identical to POST /auth/sign-in, so the rest of this guide applies unchanged.
The endpoint both signs in and signs up — there is no separate registration call:
- No account for the Google email yet — one is created, already verified, with no password of its own. The user can set one later through
POST /api/v1/auth/password-reset/requestif they also want password sign-in. - An account exists — the Google account is linked to it. Because Google attests that the user owns the email (
email_verified), this also verifies an account that was registered by email but never activated, so a stuck registration is resolved by signing in with Google. - Either way, no business is created. A brand-new user has no business until they complete onboarding via
POST /api/v1/business, exactly as with email registration.
A credential Google did not issue for Brane — or one whose email Google has not verified — is rejected with 401024. If the deployment has no GOOGLE_CLIENT_ID configured, the endpoint returns 500001; Google sign-in is unavailable until an operator sets it.
Sign in with Google by redirect
Google Identity Services runs only on origins registered with Google, and Google allows no wildcards, so it can't run on a business's own subdomain (<business>.incrm.app). There the app signs in with Google by redirect instead, with top-level navigations only:
- The page sends the browser to
GET /api/v1/auth/google/redirect?returnTo=<page>, where<page>is an address of the app (an origin the API takes credentialed requests from; any other answers400281). - The API remembers the sign-in in a short-lived cookie and sends the browser to Google.
- Google posts the ID token to
POST /api/v1/auth/google/callback. The API checks that the answer belongs to the sign-in this browser started, signs the user in asPOST /auth/googledoes, sets therefreshTokencookie and sends the browser back to<page>with?google=signed-in, or?google=failed. A user who cancels at Google returns to<page>unchanged. - The page gets its access token from
POST /api/v1/auth/token/refresh.
These are browser endpoints, not API calls: they aren't in the OpenAPI document.
Using the Access Token
Include the token in all subsequent requests:
curl https://api.incrm.app/api/v1/client \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."
TypeScript SDK:
import { InCRMClient } from '@incrm-app/sdk'
const client = new InCRMClient({
baseUrl: 'https://api.incrm.app',
accessToken: 'eyJhbGciOiJIUzI1NiIs...',
})
Token Expiration
| Token | Default TTL |
|---|---|
| Access token | 30 minutes |
| Refresh token | 60 days, counted again from every refresh |
Refreshing Tokens
When the access token expires, use the refresh cookie to get a new one:
curl -X POST https://api.incrm.app/api/v1/auth/token/refresh \
-b "refreshToken=<cookie-value>"
Response:
{
"success": true,
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiIs..."
}
}
A new refresh token cookie is also set. Use the new refresh token from then on: the one you sent stops working 60 seconds later (the grace lets several tabs refresh with the same cookie at once). If the refresh token is missing, invalid or retired, you receive:
{
"success": false,
"error": {
"code": 401012,
"message": "Refresh token not found in whitelist"
}
}
Logout
curl -X POST https://api.incrm.app/api/v1/auth/logout \
-H "Authorization: Bearer <accessToken>"
Returns HTTP 204 No Content. Invalidates both the access token and refresh token.
Resetting the password (POST /api/v1/auth/password-reset/confirm) ends every session of the user: all their access and refresh tokens stop working.
API Key Authentication
API keys are designed for server-to-server integrations, CI/CD pipelines, and AI agent access.
Creating an API Key
Create a key via the API (requires JWT auth with apiKeys.create permission):
curl -X POST https://api.incrm.app/api/v1/api-key \
-H "Authorization: Bearer <accessToken>" \
-H "Content-Type: application/json" \
-d '{"name": "My Integration", "scopes": ["clients.read", "invoices.read"]}'
Response:
{
"success": true,
"data": {
"id": "abc123",
"name": "My Integration",
"key": "incrm_dGhpcyBpcyBhIHRlc3Qga2V5...",
"keyPrefix": "incrm_dGhpcyB",
"scopes": ["clients.read", "invoices.read"],
"expiresAt": null,
"isActive": true,
"createdAt": "2025-01-01T00:00:00.000Z"
}
}
Important: The
keyfield is only returned once at creation time. Store it securely — it cannot be retrieved later.
Using an API Key
Send the key via the X-Api-Key header:
curl https://api.incrm.app/api/v1/client \
-H "X-Api-Key: incrm_dGhpcyBpcyBhIHRlc3Qga2V5..."
Alternatively, you can pass it as a Bearer token (the incrm_ prefix signals API key auth):
curl https://api.incrm.app/api/v1/client \
-H "Authorization: Bearer incrm_dGhpcyBpcyBhIHRlc3Qga2V5..."
TypeScript SDK:
import { InCRMClient } from '@incrm-app/sdk'
const client = new InCRMClient({
baseUrl: 'https://api.incrm.app',
apiKey: 'incrm_dGhpcyBpcyBhIHRlc3Qga2V5...',
})
Scopes
API keys can be scoped to specific permissions. If scopes is empty or omitted, the key inherits all permissions of the user who created it. When scopes are specified, the key is restricted to only those actions.
Scope format: resource.action (e.g., clients.read, invoices.create).
Key Lifecycle
- Keys can have an optional
expiresAtdate. Expired keys are rejected with error401020. - Keys can be revoked (deactivated) via
DELETE /api/v1/api-key. Revoked keys are rejected with error401021.
Auth Error Responses
| HTTP Status | Error Code | Message |
|---|---|---|
| 400 | 400281 | returnTo must be the address of a page of the app |
| 401 | 401001 | Invalid credentials |
| 401 | 401004 | Email is not verified |
| 401 | 401010 | Invalid access token |
| 401 | 401011 | Access token not found in whitelist |
| 401 | 401012 | Refresh token not found in whitelist |
| 401 | 401013 | Invalid refresh token |
| 401 | 401014 | Insufficient permissions for this action |
| 401 | 401015 | Business access required |
| 401 | 401020 | API key has expired |
| 401 | 401021 | API key has been revoked |
| 401 | 401022 | Invalid API key |
| 401 | 401024 | Invalid Google credential |
| 403 | 403000 | Forbidden resource |
| 500 | 500001 | Google sign-in is not configured |
Rate Limits
Limits are counted per client IP address and per endpoint. Each endpoint has its own budget for each address, so requests to one endpoint never use up another's, and clients at different addresses never share a budget. Clients behind one shared address (an office network or a NAT gateway) do share it.
Unless listed below, an endpoint allows 50 requests per 60 seconds from each address. Some endpoints are stricter:
| Endpoint | Limit |
|---|---|
POST /api/v1/auth/resend-activation |
5 requests per 60 seconds |
POST /api/v1/auth/google |
10 requests per 60 seconds |
POST /api/v1/public/inquiry |
5 requests per 300 seconds |
POST /api/v1/public/site-visit (a website's page views) |
120 requests per 60 seconds |
POST /api/v1/public/booking, POST /api/v1/public/booking/manage/{token}/cancel, POST /api/v1/public/booking/manage/{token}/reschedule |
5 requests per 600 seconds each |
GET /api/v1/public/booking/... (booking types, booking page, free slots, a booking by its token) |
60 requests per 60 seconds each |
GET /api/v1/public/shared-files/{token}, POST /api/v1/public/shared-files/{token}/download |
30 requests per 60 seconds each |
POST /api/v1/contact |
5 requests per 60 seconds |
Responses carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (seconds until the window resets) for the endpoint that was called. Exceeding a limit returns 429 with error code 429000 and a Retry-After header (seconds).
POST /api/v1/ai-chat/chat also limits the Brane answers a user has in progress at once: 3 on each API server. Another request is refused with 429 and error code 429002 until one of them finishes or is stopped. When the AI model is overloaded, the request returns 429 with error code 429001, or, if the answer has already started streaming, the stream ends with an error part carrying that code. Wait a minute before trying again.
POST /api/v1/ai-chat/transcribe (a voice message for Brane, turned into text) also allows each user 10 voice messages per 60 seconds on each API server. Another one is refused with 429 and error code 429003.
CORS
When using API keys, CORS is configured with credentials: false and origin: true (any origin). This allows API keys to be used from any domain without credential cookies.
JWT-based auth requires requests from an allowed origin and uses credentials: true for cookie-based refresh tokens.
Opaque (Origin: null) requests
The in-browser landing-page preview renders generated code inside a sandboxed srcdoc iframe (sandbox="allow-scripts"), which is an opaque origin. Requests it issues therefore carry Origin: null. To let a previewed page load live data at runtime, Origin: null is accepted only for explicitly public, read-only endpoints, with credentials disabled — currently just GET /api/v1/capabilities. Authenticated application endpoints continue to reject Origin: null, so the preview origin can never reach tenant data.
Public booking API
/api/v1/public/booking (see Bookings) answers requests from any origin, with or without X-Api-Key, so a business's website on any domain can book for its visitors. Credentials are disabled for every origin except Brane's own app. The landing-page preview (Origin: null) may only read there (GET); booking, cancelling and moving are rejected from it.
A website's page views
POST /api/v1/public/site-visit takes the page views a business's website counts without cookies. It answers any origin with credentials disabled, and accepts the publishable key in the JSON body (key) of a text/plain request as well as in X-Api-Key, so a page sends its views as simple requests, without a CORS preflight. The landing-page preview (Origin: null) is rejected.