Aventora CRM — Laravel integration guide
Audience: Laravel teams integrating Aventora CRM without the official aventora/crm-laravel plugin.
This document is the single shareable reference for server-side provisioning, SSO login, embedded navigation, and profile avatars. Keep it updated when provisioning or SSO behavior changes.
Related docs:
- CRM Complete Guide — full CRM operator guide (deployment, presets, Sales Cockpit, TIPS, contacts API)
- API Security Model — platform security overview
- Official plugin (optional): Composer package
aventora/crm-laravel— same API as a custom integration
Table of contents
- What you are building
- Configuration
- Security rules
- SSO flow
- Laravel implementation pattern
- API reference
- Embedded navigation (
page+embedded) - Profile avatar (
avatar) - Custom settings (
customSettings) - Workspace contact limits (
maxContactCount) - Workspace modes
- Resolve rules and errors
- Stored workspace shortcut
- Host app navigation pattern
- Testing checklist
- Troubleshooting
What you are building
Your Laravel app authenticates the user. When they open CRM:
- Backend calls CRM provisioning APIs with a shared secret.
- CRM returns a short-lived
workspaceUrl(one-time login link). - Browser opens that URL (redirect, popup, or iframe).
You never put the provisioning secret in JavaScript. You never build the verify URL yourself — use the workspaceUrl from CRM.
| Concept | Meaning |
|---|---|
| User | Person who logs into CRM (provisioned via /auth/provision/*) |
| Workspace | Tenant/isolated CRM instance ({subdomain}.crm.example.com) |
| Contact | A person record in CRM — created via /rest/people with a workspace API key, not the provisioning secret |
Provisioning and SSO always use the apex CRM API URL (CRM_API_URL). Contact writes use the workspace host — see CRM Complete Guide — Contacts.
Engagement Hub billing (partners)
If you bill customers directly and configure Hub plans or top-ups via API, the adminEmail you pass to POST /auth/provision/workspace (or POST /auth/provision/resolve) is also the Hub billing lookup key. After CRM auto-provision creates the Hub account (~1–2 minutes), resolve account_id with:
GET {HUB_BASE_URL}/accounts/email/{adminEmail}
See Engagement Hub Billing API for the full partner flow (permissions, plan setup, top-ups). Partners do not use domain slug or CRM adminUserId for Hub billing lookup.
Configuration
Add to Laravel .env:
CRM_API_URL=https://crm.example.com
CRM_PROVISIONING_SECRET=your-shared-secret
| Variable | Required | Description |
|---|---|---|
CRM_API_URL | Yes | Apex URL for all /auth/provision/* calls (no trailing slash) |
CRM_PROVISIONING_SECRET | Yes | Must match CRM server PROVISIONING_SECRET |
Optional app settings (store in config or database per tenant):
| Setting | Description |
|---|---|
crm_workspace_id | If already known, skip resolve and call login-token only |
crm_workspace_api_key | Workspace API key from first resolve (twentyApiKey), or from POST /auth/provision/rotate-api-key if the first key was not stored. Use for /rest/* on the workspace host |
crm_tenant_subdomain | Shared workspace subdomain (omit for personal workspace per user) |
industryPreset / industryProfile | Only when creating a new workspace via resolve |
Example config/crm.php:
<?php
return [
'api_url' => env('CRM_API_URL'),
'provisioning_secret' => env('CRM_PROVISIONING_SECRET'),
'default_tenant_subdomain' => env('CRM_DEFAULT_TENANT_SUBDOMAIN'),
];
Security rules
- Call
/auth/provision/*only from Laravel (controller, job, service) — never from the browser. - Send
Authorization: Bearer {CRM_PROVISIONING_SECRET}on every provisioning request. - Return only the final
workspaceUrl(or a JSON wrapper around it) to the frontend. - Do not expose
CRM_PROVISIONING_SECRETor workspace API keys in client-side code. Ifresolveorrotate-api-keyreturnstwentyApiKey, persist it in Laravel (database/secret store) and omit it from any browser JSON.
SSO flow
Step 1 — Resolve workspace and user
POST {CRM_API_URL}/auth/provision/resolve
Authorization: Bearer {CRM_PROVISIONING_SECRET}
Content-Type: application/json
{
"email": "agent@example.com",
"firstName": "Alex",
"lastName": "Agent",
"tenantSubdomain": "acme",
"tenantDisplayName": "Acme Inc",
"industryPreset": "insurance",
"industryProfile": "general_insurance_advisor",
"avatar": "https://cdn.example.com/avatars/agent.png",
"customSettings": {
"engagementInitiatorPhone": "+15551234567"
}
}
Response:
{
"workspaceId": "uuid",
"subdomain": "acme",
"userId": "uuid",
"wasCreated": {
"workspace": false,
"user": true
}
}
When wasCreated.workspace is true, the response also includes twentyApiKey (shown once). Store it server-side for later CRM REST/MCP calls. Repeat resolve omits the field.
{
"workspaceId": "uuid",
"subdomain": "acme",
"userId": "uuid",
"wasCreated": {
"workspace": true,
"user": true
},
"twentyApiKey": "<jwt>"
}
| Field | Description |
|---|---|
workspaceId | Use in step 2 |
subdomain | Workspace subdomain (for logging/display) |
userId | CRM user ID (store if useful) |
wasCreated.workspace | true if a new workspace was created |
wasCreated.user | true if a new CRM user was created |
twentyApiKey | Present only when a new workspace was created. Long-lived Admin-role workspace API key. Persist server-side; never send to the browser. |
Omit tenantSubdomain for personal workspace mode (one workspace per email).
Step 2 — Login token (SSO URL)
POST {CRM_API_URL}/auth/provision/login-token
Authorization: Bearer {CRM_PROVISIONING_SECRET}
Content-Type: application/json
{
"workspaceId": "uuid-from-resolve",
"email": "agent@example.com",
"firstName": "Alex",
"lastName": "Agent",
"page": "/objects/people",
"embedded": true,
"avatar": "https://cdn.example.com/avatars/agent.png",
"customSettings": {
"engagementInitiatorPhone": "+15551234567"
}
}
Response:
{
"loginToken": "...",
"expiresAt": "2026-06-13T21:00:00.000Z",
"workspaceUrl": "https://acme.crm.example.com/verify?loginToken=...&aventoraSso=1&returnToPath=%2Fobjects%2Fpeople&aventoraEmbedded=1"
}
Pass "embedded": true with page when the host needs chromeless CRM (iframe). Omit embedded for new-tab deep links so left navigation remains visible.
| Field | Description |
|---|---|
workspaceUrl | Open this in the browser — complete SSO URL |
loginToken | Short-lived token (also embedded in workspaceUrl) |
expiresAt | Token expiry (ISO 8601) |
SSO session behavior:
workspaceUrlalways includesaventoraSso=1→ CRM hides Log out (user exits via your app).- When
pageis set → URL includesreturnToPath(redirect after verify). - When
embedded: trueis also set → URL includesaventoraEmbedded=1→ CRM hides left nav and mobile bottom nav (iframe embeds only).
login-token creates the user and workspace membership if missing (idempotent).
Laravel implementation pattern
HTTP client service
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\RequestException;
class CrmProvisioningClient
{
public function resolve(array $payload): array
{
return $this->post('auth/provision/resolve', $payload);
}
public function loginToken(array $payload): array
{
return $this->post('auth/provision/login-token', $payload);
}
public function rotateWorkspaceApiKey(array $payload): array
{
return $this->post('auth/provision/rotate-api-key', $payload);
}
public function openCrm(
string $email,
?string $firstName = null,
?string $lastName = null,
?string $tenantSubdomain = null,
?string $page = null,
?string $avatar = null,
?array $customSettings = null,
): array {
$resolvePayload = array_filter([
'email' => $email,
'firstName' => $firstName,
'lastName' => $lastName,
'tenantSubdomain' => $tenantSubdomain,
'avatar' => $avatar,
'customSettings' => $customSettings,
], fn ($value) => $value !== null);
$resolved = $this->resolve($resolvePayload);
$loginPayload = array_filter([
'workspaceId' => $resolved['workspaceId'],
'email' => $email,
'firstName' => $firstName,
'lastName' => $lastName,
'page' => $page,
'avatar' => $avatar,
'customSettings' => $customSettings,
], fn ($value) => $value !== null);
$token = $this->loginToken($loginPayload);
return [
'url' => $token['workspaceUrl'],
'loginToken' => $token['loginToken'],
'expiresAt' => $token['expiresAt'],
'workspaceId' => $resolved['workspaceId'],
'subdomain' => $resolved['subdomain'],
'userId' => $resolved['userId'],
'page' => $page,
'twentyApiKey' => $resolved['twentyApiKey'] ?? null,
];
}
private function post(string $path, array $body): array
{
$response = Http::baseUrl(rtrim(config('crm.api_url'), '/'))
->withToken(config('crm.provisioning_secret'))
->acceptJson()
->post($path, $body)
->throw();
return $response->json();
}
}
Authenticated controller route
// routes/web.php or routes/api.php (must require auth middleware)
Route::get('/open-crm', function (Illuminate\Http\Request $request, App\Services\CrmProvisioningClient $crm) {
$user = $request->user();
$page = $request->query('page');
$page = is_string($page) && trim($page) !== '' ? trim($page) : null;
$avatar = $user->avatar_url ?? null; // your app's public profile image URL
$customSettings = null;
if (is_string($user->phone) && trim($user->phone) !== '') {
$customSettings = [
'engagementInitiatorPhone' => trim($user->phone),
];
}
$result = $crm->openCrm(
email: $user->email,
firstName: $user->first_name,
lastName: $user->last_name,
tenantSubdomain: config('crm.default_tenant_subdomain'),
page: $page,
avatar: $avatar,
customSettings: $customSettings,
);
if (!empty($result['twentyApiKey'])) {
// Persist once on the tenant/workspace row — do not send this to the browser.
$user->tenant?->update(['crm_workspace_api_key' => $result['twentyApiKey']]);
unset($result['twentyApiKey']);
}
return response()->json($result);
})->middleware('auth');
Frontend opens result.url in an iframe, popup, or full redirect. Do not include twentyApiKey in that JSON.
Official plugin: Crm::openCrm() exposes twentyApiKey on SsoResult for server-side callers. GET /crm/sso uses toBrowserArray() and omits the key. Listen for Aventora\Crm\Events\CrmWorkspaceProvisioned to persist it:
use Aventora\Crm\Events\CrmWorkspaceProvisioned;
use Illuminate\Support\Facades\Event;
Event::listen(CrmWorkspaceProvisioned::class, function (CrmWorkspaceProvisioned $event) {
Tenant::where('crm_workspace_id', $event->workspaceId)
->update(['crm_workspace_api_key' => $event->twentyApiKey]);
});
If the first-create key was not stored, call Crm::rotateWorkspaceApiKey($workspaceId) (or pass the subdomain as the second argument). Persist twentyApiKey the same way — listen for CrmWorkspaceApiKeyRotated, not CrmWorkspaceProvisioned:
use Aventora\Crm\Events\CrmWorkspaceApiKeyRotated;
use Illuminate\Support\Facades\Event;
Event::listen(CrmWorkspaceApiKeyRotated::class, function (CrmWorkspaceApiKeyRotated $event) {
Tenant::where('crm_workspace_id', $event->workspaceId)
->update(['crm_workspace_api_key' => $event->twentyApiKey]);
});
API reference
All routes: Authorization: Bearer {CRM_PROVISIONING_SECRET} on {CRM_API_URL}.
| Method | Path | Purpose |
|---|---|---|
POST | /auth/provision/resolve | Find or create workspace + user; optional customSettings. Returns twentyApiKey on first workspace create |
POST | /auth/provision/rotate-api-key | Revoke Partner Provisioning keys and mint a new Admin JWT (twentyApiKey) |
POST | /auth/provision/login-token | SSO URL; optional page, embedded, avatar, customSettings |
POST | /auth/provision/user | Add user to existing workspace |
POST | /auth/provision/move-user | Move user from one workspace to another (access only) |
POST | /auth/provision/workspace | Create or reuse named workspace; optional maxContactCount |
POST | /auth/provision/workspace-contact-limit | Set maxContactCount on an existing workspace |
GET | /auth/provision/workspace?subdomain= | Lookup workspace by subdomain |
POST /auth/provision/resolve
| Field | Required | Description |
|---|---|---|
email | Yes | User email (lowercased by CRM) |
firstName | No | Used when creating user |
lastName | No | Used when creating user |
tenantSubdomain | No | Shared workspace subdomain; omit for personal workspace |
tenantDisplayName | No | Display name when creating a new shared workspace |
industryPreset | No | Only applied when creating a new workspace — see Industry presets and profiles |
industryProfile | No | Profile within preset — see Industry presets and profiles |
avatar | No | Public http/https image URL — see Profile avatar |
customSettings | No | Per-user CRM settings — see Custom settings |
maxContactCount | No | Positive integer — only applied when creating a new workspace; see Workspace contact limits |
Response always includes workspaceId, subdomain, userId, and wasCreated. When a new workspace is created, it also includes twentyApiKey (store once; omit from browser responses). Repeat resolve omits the field. If the key was not stored, use rotate-api-key.
POST /auth/provision/rotate-api-key
Revokes active API keys named Partner Provisioning and mints a new Admin-role JWT. Same Authorization: Bearer {CRM_PROVISIONING_SECRET} as resolve. Does not revoke keys created in CRM Settings, Hub sync (Engagement Hub CRM Sync), or demo (Demo Phone Sync) keys.
POST {CRM_API_URL}/auth/provision/rotate-api-key
Authorization: Bearer {CRM_PROVISIONING_SECRET}
Content-Type: application/json
{
"workspaceId": "uuid",
"workspaceSubdomain": "acme"
}
One of workspaceId or workspaceSubdomain is required. 404 if the workspace is missing; 400 if neither identifier is sent.
{
"workspaceId": "uuid",
"subdomain": "acme",
"twentyApiKey": "<jwt>",
"revokedKeyCount": 1
}
| Field | Description |
|---|---|
workspaceId | CRM workspace ID |
subdomain | Workspace subdomain |
twentyApiKey | New long-lived Admin-role workspace API key (shown once). Persist server-side; never send to the browser. |
revokedKeyCount | How many Partner Provisioning keys were revoked. 0 is valid if the workspace never had a partner key. |
Official plugin: Crm::rotateWorkspaceApiKey($workspaceId = null, $workspaceSubdomain = null) posts to this path and fires Aventora\Crm\Events\CrmWorkspaceApiKeyRotated.
POST /auth/provision/login-token
| Field | Required | Description |
|---|---|---|
workspaceId | Yes | From resolve or your stored setting |
email | Yes | Same email as authenticated Laravel user |
firstName | No | Used when creating user |
lastName | No | Used when creating user |
page | No | CRM path for deep-link / embedded mode — see Embedded navigation |
embedded | No | When true with page, URL includes aventoraEmbedded=1 and CRM hides left nav. Required for chromeless partner embeds. |
avatar | No | Public image URL — see Profile avatar |
customSettings | No | Per-user CRM settings — see Custom settings |
engagementInitiatorPhone | No | Legacy top-level field; prefer customSettings.engagementInitiatorPhone. North American phone; stored on membership when empty |
POST /auth/provision/user
Add a user to an existing workspace without SSO:
{
"workspaceId": "uuid",
"email": "colleague@example.com",
"firstName": "Sam",
"lastName": "Lee",
"avatar": "https://cdn.example.com/avatars/sam.png",
"customSettings": {
"engagementInitiatorPhone": "+15551234567"
}
}
Response: { "userId", "email", "wasCreated" }.
For normal SSO, login-token alone is usually enough — it creates membership if missing.
POST /auth/provision/move-user
Move a user's workspace membership from one tenant to another. This is an access-only operation: CRM contacts, engagements, and cockpit data stay in the source workspace and are not copied to the target.
Use when a user was provisioned to the wrong tenant subdomain and should log into a different workspace instead.
| Field | Required | Description |
|---|---|---|
email | Yes | User email (lowercased by CRM) |
fromWorkspaceId | One of pair | Source workspace UUID |
fromWorkspaceSubdomain | One of pair | Source workspace subdomain |
toWorkspaceId | One of pair | Target workspace UUID |
toWorkspaceSubdomain | One of pair | Target workspace subdomain |
firstName | No | Updates user profile when provided |
lastName | No | Updates user profile when provided |
avatar | No | Public image URL for workspace member profile in target workspace |
customSettings | No | Per-user CRM settings applied in the target workspace — see Custom settings |
{
"email": "agent@example.com",
"fromWorkspaceSubdomain": "wrong-tenant",
"toWorkspaceSubdomain": "correct-tenant",
"firstName": "Alex",
"lastName": "Agent"
}
Response:
{
"userId": "uuid",
"email": "agent@example.com",
"fromWorkspaceId": "uuid",
"fromSubdomain": "wrong-tenant",
"toWorkspaceId": "uuid",
"toSubdomain": "correct-tenant",
"userRestored": false
}
After a successful move:
- Update your stored
crm_workspace_id(or equivalent) totoWorkspaceId. - Issue the next SSO with
login-tokenusing the target workspace ID.
Laravel example:
$result = Crm::moveUser([
'email' => $user->email,
'fromWorkspaceSubdomain' => $oldTenant->crm_subdomain,
'toWorkspaceSubdomain' => $newTenant->crm_subdomain,
'firstName' => $user->first_name,
'lastName' => $user->last_name,
]);
$tenant->update(['crm_workspace_id' => $result['toWorkspaceId']]);
Rules:
- User must belong to exactly one workspace (the
fromworkspace). - Source workspace must have another member (cannot move the sole member out).
- User cannot be the only admin in the source workspace.
- Target workspace must be active.
- Does not migrate People/contacts or engagement history (planned separately).
POST /auth/provision/workspace
Explicitly create a shared workspace (admin onboarding):
{
"adminEmail": "admin@example.com",
"subdomain": "acme",
"displayName": "Acme Insurance",
"adminFirstName": "Admin",
"adminLastName": "User",
"industryPreset": "insurance",
"industryProfile": "general_insurance_advisor",
"maxContactCount": 500
}
| Field | Required | Description |
|---|---|---|
adminEmail | Yes | Admin user email |
subdomain | No | Workspace subdomain |
displayName | No | Workspace display name |
adminFirstName | No | Used when creating admin user |
adminLastName | No | Used when creating admin user |
industryPreset | No | Only applied when creating a new workspace — see Industry presets and profiles |
industryProfile | No | Profile within preset — see Industry presets and profiles |
maxContactCount | No | Positive integer — only applied when creating a new workspace; see Workspace contact limits |
Idempotent: existing subdomain returns the workspace and ensures admin user membership.
Industry presets and profiles
An industry preset seeds a workspace's labels, custom fields, views, pipelines, dashboards, workflow templates, and Sales Cockpit rules. Pass industryPreset (and usually industryProfile) on the call that creates the workspace — resolve (new subdomain or personal), workspace, or demo-tenant.
Key rule: presets apply only at workspace creation. Reusing or joining an existing workspace ignores the fields — the workspace keeps whatever preset it was created with. To change a preset afterward, an operator runs the workspace:apply-industry-preset CLI command.
The authoritative list is served at runtime by GET {CRM_API_URL}/auth/provision/industry-preset-catalog. Current values:
industryPreset | industryProfile values | Default profile | Profile required? |
|---|---|---|---|
real_estate | buyer_agent, listing_agent, team_leader, broker, general_realtor | general_realtor | Yes |
insurance | personal_lines, commercial_lines, benefits_advisor, broker_owner, general_insurance_advisor | general_insurance_advisor | Yes |
mortgage | mortgage_agent, mortgage_broker, commercial_mortgage, team_lead, broker_owner | mortgage_agent | Yes |
financial_advisor | financial_advisor, wealth_advisor, retirement_specialist, team_lead, practice_owner | financial_advisor | Yes |
generic (aliases: general, other) | default | default | No |
- For the four industry presets,
industryProfileis required — a missing or unknown profile returns HTTP 400. - For
generic,industryProfileis optional and falls back todefault. - An unknown
industryPresetreturns HTTP 400. Legacy onboarding idsgeneralandotherresolve togeneric.
Prefer fetching the catalog endpoint if you build a preset picker, so your UI stays in sync when new presets or profiles are added. See also CRM Complete Guide — Industry presets.
Billing: Save adminEmail from this request. Partners use it with Hub GET /accounts/email/{email} to obtain account_id for billing API calls — see Engagement Hub Billing API.
Embedded navigation (page + embedded)
Use when your Laravel app owns navigation (sidebar, submenu) and CRM opens in an iframe or panel.
Framing (env on the CRM server):
| Deploy | FRAME_ANCESTORS | Browser framing |
|---|---|---|
| Staging / non-partner (default) | unset / empty | X-Frame-Options: DENY — iframes blocked |
| Partner-facing CRM | comma-separated https origins of host apps | CSP frame-ancestors 'self' <list> — only those parents may embed |
Example partner env:
FRAME_ANCESTORS=https://bayviewhub.ca,https://recagenthub.ca,https://signatureagenthub.com,https://teamadmiralhub.com,https://hallmarkhub.com
Each listed origin also allows its subdomains automatically (CSP https://*.host), so https://tenant.bayviewhub.ca works when https://bayviewhub.ca is listed. You do not need to list every workspace subdomain.
If the console shows X-Frame-Options: deny, either you are on a staging CRM (expected) or the partner CRM is missing FRAME_ANCESTORS / nginx is forcing DENY. Do not set X-Frame-Options in nginx — Nest owns framing via this env.
Google / Microsoft Connect Account: OAuth consent pages from Google and Microsoft cannot run inside an iframe (their own frame policy). Keep the Accounts settings screen embedded if you want, but open Connect Google / Connect Microsoft in a top-level tab or popup (window.open(…, '_blank')), or open the whole Accounts SSO URL in a new tab.
| Mode | login-token body | CRM behavior |
|---|---|---|
| Full CRM | No page | Normal left nav + mobile bottom nav |
| Deep link (nav visible) | page only | Lands on path; left nav stays visible |
| Embedded page (chromeless) | page and "embedded": true | Nav hidden; user lands on your path |
Custom integrations that call POST /auth/provision/login-token directly must send "embedded": true to hide the left nav. page alone is not enough.
Rules
- Format: internal path starting with
/- Valid:
/cockpit,/objects/people,/settings/profile - Invalid:
cockpit,https://...,//evil.com
- Valid:
- Validation: invalid paths → HTTP 400 from
login-token - No fixed page whitelist: CRM accepts any internal path except the blocked routes below. The tables in this section are known CRM routes, not an exhaustive server enum.
- Custom objects:
/objects/{pluralName}or/object/{singularName}/{recordId}
Blocked paths (400)
Do not pass auth/onboarding routes:
/welcome,/verify,/verify-email/create/*,/invite-team,/plan-required,/book-call*/reset-password/*/(root alone)
Settings in embedded mode (read this first)
In embedded mode CRM hides all navigation, including the settings sidebar that appears in full CRM.
| Do not use | Why |
|---|---|
A single host menu item labeled Settings with page=/settings/profile | Lands on Profile only — not a settings hub |
page=/settings | No CRM route for bare /settings — use a specific path below |
Your Laravel app must expose one menu item per settings screen you want users to reach (Profile, Accounts, Experience, etc.), each with its own page path and SSO call.
In full CRM (no page), users open Settings from CRM nav and see the settings menu. That menu is not available in embedded mode.
Available page values
Main app screens
| Screen | page | Default SSO access |
|---|---|---|
| Sales Cockpit | /cockpit | Yes |
| People (list) | /objects/people | Yes |
| Companies (list) | /objects/companies | Yes |
| Opportunities (list) | /objects/opportunities | Yes |
| Tasks (list) | /objects/tasks | Yes |
| Notes (list) | /objects/notes | Yes |
| Dashboards (list) | /objects/dashboards | Yes (if enabled in workspace) |
| Workflows (list) | /objects/workflows | Yes (if enabled in workspace) |
Record detail (deep links)
Pattern: /object/{singularName}/{recordId}
| Screen | page | Default SSO access |
|---|---|---|
| Person record | /object/person/{uuid} | Yes |
| Company record | /object/company/{uuid} | Yes |
| Opportunity record | /object/opportunity/{uuid} | Yes |
Replace {uuid} with the CRM record ID.
User settings (typical host menu items)
These are the screens most partners link from a User submenu in Laravel:
| Screen | page | Default SSO access |
|---|---|---|
| Profile | /settings/profile | Yes — no extra role flags |
| Experience (theme / locale) | /settings/experience | Yes — no extra role flags |
| Connected accounts (hub) | /settings/accounts | Requires Connected accounts on Aventora User role |
| Account emails | /settings/accounts/emails | Requires Connected accounts |
| Account calendars | /settings/accounts/calendars | Requires Connected accounts |
Workspace settings (admin-style screens)
Use separate host menu items only when the user should manage workspace configuration. The workspace owner (adminEmail on resolve / provision/workspace) is assigned the standard Admin role and can open these screens. Extra SSO users (provision/user or later login-token memberships) are provisioned with the Aventora User role, which does not include these permissions by default.
| Screen | page | CRM permission flag |
|---|---|---|
| Workspace general | /settings/general | WORKSPACE |
| Data model | /settings/objects | DATA_MODEL |
| Members | /settings/members | WORKSPACE_MEMBERS |
| Roles | /settings/roles | ROLES |
| Domains | /settings/domains | WORKSPACE |
| Billing | /settings/billing | WORKSPACE (billing must be enabled) |
| APIs & Webhooks | /settings/api-webhooks | API_KEYS_AND_WEBHOOKS |
| Apps | /settings/applications | WORKSPACE (apps feature flag) |
| AI | /settings/ai | WORKSPACE (AI feature flag) |
| Security | /settings/security | SECURITY |
| Updates | /settings/updates | WORKSPACE |
| Admin panel | /settings/admin-panel | Server admin only (not Aventora User) |
Sub-pages with IDs follow the same pattern, e.g. /settings/roles/{roleId}.
Custom workspace objects
/objects/{objectPluralName}
/object/{objectSingularName}/{recordId}
Example: custom object plural deals → /objects/deals
Permissions and access
SSO workspace owners (adminEmail) are added with the standard Admin role and can open all workspace settings. Extra SSO users are added with the Aventora User role. By default extra users can use main app screens and Profile / Experience. Other settings paths are accepted by login-token but CRM redirects to Profile if the role lacks the required permission flag.
To grant additional settings screens to extra users (not the workspace owner):
- In CRM (as workspace admin): Settings → Roles → Aventora User
- Enable the permission flags from the table above (e.g. Connected accounts, Workspace, Members)
- Test each host menu item with a fresh SSO call
See also: CRM Complete Guide — Available page values for the operator reference (same paths).
Important
- Each submenu click should trigger a new
login-tokencall with a differentpage, then open the newworkspaceUrl. - Do not rely on in-CRM navigation when embedded — main nav and settings sidebar are hidden for the SSO session.
- Use one
pageper screen in your Laravel menu; do not use a generic Settings item unless you only want Profile. - Log out remains hidden in CRM for all SSO sessions; users exit via your Laravel app.
Profile avatar (avatar)
Optional on resolve, login-token, and provision/user.
"avatar": "https://cdn.example.com/avatars/user.png"
| Rule | Detail |
|---|---|
| Protocol | http or https only |
| Max length | 2048 characters |
| Access | CRM server must be able to fetch the URL over the public internet |
| Invalid URL | HTTP 400 |
| Unreachable / not an image | Provisioning continues; avatar skipped (logged server-side) |
Behavior:
- New member: CRM downloads the image, stores it, sets workspace member profile picture.
- Existing member: CRM re-downloads and updates avatar on each SSO when
avataris provided. - Omit
avatarto leave the current CRM picture unchanged.
Pass your Laravel user's profile photo URL (S3, Gravatar, etc.) on every SSO call to keep CRM in sync.
Custom settings (customSettings)
Optional JSON object on resolve, login-token, user, and move-user. Use it to pass per-user CRM settings from Laravel without waiting for new top-level API fields.
| Key | Type | CRM behavior |
|---|---|---|
engagementInitiatorPhone | string | Sets userWorkspace.aventoraEngagementInitiatorPhone when empty (North American 10-digit normalization). Users can also edit this in CRM under Settings → Profile → Engagement callback number. |
"customSettings": {
"engagementInitiatorPhone": "6473710396"
}
Unknown keys are ignored. CRM may add support for additional keys over time.
Official Laravel plugin: CrmService::openCrm() and provisionUser() accept an optional $customSettings array and forward it to CRM.
Legacy: login-token still accepts top-level engagementInitiatorPhone. Prefer customSettings for new code.
Workspace contact limits (maxContactCount)
Each workspace has a tamper-protected maxContactCount: the maximum number of Person records (contacts) allowed in that workspace. CRM enforces this on person creates via GraphQL, REST, email/calendar sync, and Hub contact writes.
| Rule | Detail |
|---|---|
| What counts | User-created and integration-created people |
| What does not count | System seed contacts (createdBySource = SYSTEM) |
| When exceeded | Creates fail with Too many contacts |
| Direct DB edits | Blocked — use provisioning API only |
New workspaces inherit the CRM platform default unless you pass maxContactCount at creation time. Operators can also set a one-time platform default via CRM env DEFAULT_WORKSPACE_MAX_CONTACT_COUNT on first bootstrap (fallback 20); see CRM operational scripts — contact limits.
Set limit when creating a workspace
Pass optional maxContactCount on POST /auth/provision/resolve or POST /auth/provision/workspace. CRM applies it only when a new workspace is created — not when reusing an existing subdomain.
POST {CRM_API_URL}/auth/provision/resolve
Authorization: Bearer {CRM_PROVISIONING_SECRET}
Content-Type: application/json
{
"email": "admin@example.com",
"tenantSubdomain": "acme",
"tenantDisplayName": "Acme Inc",
"maxContactCount": 500
}
POST {CRM_API_URL}/auth/provision/workspace
Authorization: Bearer {CRM_PROVISIONING_SECRET}
Content-Type: application/json
{
"adminEmail": "admin@example.com",
"subdomain": "acme",
"displayName": "Acme Inc",
"maxContactCount": 500
}
Update limit on an existing workspace
POST {CRM_API_URL}/auth/provision/workspace-contact-limit
Authorization: Bearer {CRM_PROVISIONING_SECRET}
Content-Type: application/json
{
"subdomain": "acme",
"maxContactCount": 1000
}
Use workspaceId instead of subdomain (one identifier, not both).
Response:
{
"workspaceId": "uuid",
"maxContactCount": 1000
}
maxContactCount must be a positive integer.
Laravel example
Add a method to your provisioning client:
public function setWorkspaceContactLimit(
int $maxContactCount,
?string $workspaceId = null,
?string $subdomain = null,
): array {
if ($workspaceId === null && $subdomain === null) {
throw new InvalidArgumentException('Provide workspaceId or subdomain');
}
return $this->post('auth/provision/workspace-contact-limit', array_filter([
'workspaceId' => $workspaceId,
'subdomain' => $subdomain,
'maxContactCount' => $maxContactCount,
], fn ($value) => $value !== null));
}
On tenant onboarding (new workspace):
$resolved = $crm->resolve([
'email' => $user->email,
'tenantSubdomain' => $tenant->crm_subdomain,
'tenantDisplayName' => $tenant->name,
'maxContactCount' => $tenant->crm_max_contacts, // e.g. from your plan
]);
After plan upgrade (existing workspace):
$crm->setWorkspaceContactLimit(
maxContactCount: $tenant->crm_max_contacts,
subdomain: $tenant->crm_subdomain,
);
Call contact-limit updates from Laravel jobs or admin actions — same security rules as other provisioning routes (server-side only, never from the browser).
Workspace modes
Shared tenant workspace
Pass tenantSubdomain on resolve:
{ "email": "user@example.com", "tenantSubdomain": "acme" }
- Subdomain exists → user joins that workspace.
- Subdomain new → CRM creates workspace + user.
Personal workspace
Omit tenantSubdomain:
{ "email": "user@example.com" }
CRM creates or reuses one personal workspace per email.
When is a workspace created?
| Call | Creates workspace? |
|---|---|
resolve with new tenantSubdomain | Yes |
resolve without subdomain (no personal WS yet) | Yes |
login-token with known workspaceId | Never |
provision/workspace | Yes, if subdomain new |
Resolve rules and errors
| Situation | Result |
|---|---|
User already in workspace A; request tenant B via resolve | 400 — use move-user instead |
| User in multiple workspaces | 400 — provisioning channel supports one workspace per user |
| User exists with no workspace membership | 400 — orphan; manual CRM cleanup required |
| User move: only member of source workspace | 400 on move-user |
| User move: only admin of source workspace | 400 on move-user |
Unknown workspaceId on login-token | 404 |
Inactive workspace on login-token | 400 |
Invalid page or avatar on login-token | 400 |
Invalid maxContactCount (non-integer or < 1) | 400 |
| Person create over workspace contact limit | Too many contacts (enforced by CRM) |
| Wrong provisioning secret | 401 |
/auth/provision/* returns 404 | Wrong host, old CRM deploy, or proxy misconfiguration |
Stored workspace shortcut
If your app already stores crm_workspace_id (e.g. after CRM enablement), skip resolve:
$token = $crmClient->loginToken([
'workspaceId' => $tenant->crm_workspace_id,
'email' => $user->email,
'page' => $page,
'avatar' => $avatar,
]);
return response()->json(['url' => $token['workspaceUrl']]);
login-token never creates a workspace — only user/membership if missing.
Host app navigation pattern
| Your Laravel menu | login-token body |
|---|---|
| Open CRM (full) | omit page and embedded |
| Sales Cockpit | "page": "/cockpit", "embedded": true |
| People | "page": "/objects/people", "embedded": true |
| Companies | "page": "/objects/companies", "embedded": true |
| Profile | "page": "/settings/profile", "embedded": true |
| Experience | "page": "/settings/experience", "embedded": true |
| Connected accounts | "page": "/settings/accounts", "embedded": true |
| Account emails | "page": "/settings/accounts/emails", "embedded": true |
| Account calendars | "page": "/settings/accounts/calendars", "embedded": true |
Add workspace settings rows (Members, Roles, etc.) for the workspace owner (adminEmail) — they are Admin. Extra SSO users still need matching Aventora User permission flags — see Settings in embedded mode.
Example frontend (each menu item hits your authenticated route):
async function openCrm(page) {
const params = page ? `?page=${encodeURIComponent(page)}` : '';
const response = await fetch(`/open-crm${params}`, { credentials: 'include' });
const { url } = await response.json();
window.open(url, '_blank'); // or set iframe src
}
Testing checklist
- Health:
curl -sS "{CRM_API_URL}/healthz" - Route exists:
POST .../auth/provision/resolvewith bad secret → 401 (not 404) - Full SSO: resolve + login-token → open
workspaceUrl→ user lands in CRM - Embedded:
page=/cockpit→ no CRM nav, Cockpit visible - Invalid page:
page=/welcome→ 400 - Avatar: pass public image URL → CRM profile shows picture
- Submenu switch: two SSO calls with different
page→ correct screen each time - Settings — Profile:
page=/settings/profile→ profile form only (not a settings menu) - Settings — Accounts:
page=/settings/accounts→ accounts screen (or redirect to Profile if role lacks Connected accounts) - Settings — Experience:
page=/settings/experience→ theme/locale screen - Log out: confirm CRM hides logout; user signs out from Laravel only
Troubleshooting
# Expect 401 (not 404) — route exists, secret wrong
curl -sS -X POST "{CRM_API_URL}/auth/provision/resolve" \
-H "Authorization: Bearer wrong" \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com"}'
# Expect 200 with valid secret
curl -sS -X POST "{CRM_API_URL}/auth/provision/resolve" \
-H "Authorization: Bearer {CRM_PROVISIONING_SECRET}" \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","tenantSubdomain":"demo"}'
| Symptom | Likely cause |
|---|---|
| 404 on provision routes | Wrong CRM_API_URL, old CRM image, reverse proxy path |
| 401 | Secret mismatch between Laravel and CRM |
| 400 on resolve | Tenant conflict, multi-workspace user, orphan user |
| 400 on move-user | User not in source workspace, only member/admin, inactive target |
| 400 on login-token | Invalid page or avatar, or inactive workspace |
| Avatar not updating | URL not publicly reachable from CRM server, or not an image |
| Embedded nav still visible | page not passed to login-token, or opening CRM without new SSO URL |
| Settings menu missing in embedded mode | Expected — settings sidebar is CRM nav. Add one Laravel menu item per page path (Profile, Accounts, …) |
page=/settings/accounts opens Profile | Aventora User role lacks Connected accounts — enable in CRM Settings → Roles |
Refused to display … X-Frame-Options: deny | Staging CRM (expected) or partner CRM missing FRAME_ANCESTORS / nginx forcing DENY — set allowlist on partner deploy only |
| Google/Microsoft Connect fails only in iframe | Expected — open OAuth in a top-level tab/popup; providers block framing |
| Too many contacts on person create | Workspace at maxContactCount — raise limit via workspace-contact-limit or upgrade tenant plan in Laravel |
Changelog
| Date | Change |
|---|---|
| 2026-08-23 | Rotate partner API key: POST /auth/provision/rotate-api-key revokes Partner Provisioning keys and returns a new twentyApiKey. Official plugin: Crm::rotateWorkspaceApiKey() fires CrmWorkspaceApiKeyRotated. |
| 2026-08-21 | Admin 2FA: Workspace-owner Admin logins require 2FA unless CRM USE_2FA=false. |
| 2026-08-20 | Partner workspace API key: first-time resolve returns twentyApiKey. Store it server-side; omit from browser SSO JSON. Official plugin fires CrmWorkspaceProvisioned. |
| 2026-08-11 | Iframe embeds: CRM framing is env-driven — FRAME_ANCESTORS unset = DENY (staging); set = CSP frame-ancestors allowlist (partner). Google/Microsoft Connect Account must still open top-level. |
| 2026-07-14 | Embedded SSO: Laravel GET /crm/sso?page=... / plugin openCrm again send embedded: true by default so CRM hides left nav. Opt out with embedded=0. Direct login-token still requires explicit "embedded": true. |
| 2026-07-12 | Added Industry presets and profiles: full industryPreset / industryProfile catalog, default profiles, required-profile rules, general/other aliases, and catalog endpoint reference. Linked preset field rows to the new section. |
| 2026-07-06 | Added Workspace contact limits: maxContactCount on resolve / workspace, POST /auth/provision/workspace-contact-limit, Laravel examples |