Skip to main content

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

  1. What you are building
  2. Configuration
  3. Security rules
  4. SSO flow
  5. Laravel implementation pattern
  6. API reference
  7. Embedded navigation (page + embedded)
  8. Profile avatar (avatar)
  9. Custom settings (customSettings)
  10. Workspace contact limits (maxContactCount)
  11. Workspace modes
  12. Resolve rules and errors
  13. Stored workspace shortcut
  14. Host app navigation pattern
  15. Testing checklist
  16. Troubleshooting

What you are building

Your Laravel app authenticates the user. When they open CRM:

  1. Backend calls CRM provisioning APIs with a shared secret.
  2. CRM returns a short-lived workspaceUrl (one-time login link).
  3. 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.

ConceptMeaning
UserPerson who logs into CRM (provisioned via /auth/provision/*)
WorkspaceTenant/isolated CRM instance ({subdomain}.crm.example.com)
ContactA 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
VariableRequiredDescription
CRM_API_URLYesApex URL for all /auth/provision/* calls (no trailing slash)
CRM_PROVISIONING_SECRETYesMust match CRM server PROVISIONING_SECRET

Optional app settings (store in config or database per tenant):

SettingDescription
crm_workspace_idIf already known, skip resolve and call login-token only
crm_workspace_api_keyWorkspace 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_subdomainShared workspace subdomain (omit for personal workspace per user)
industryPreset / industryProfileOnly 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_SECRET or workspace API keys in client-side code. If resolve or rotate-api-key returns twentyApiKey, 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>"
}
FieldDescription
workspaceIdUse in step 2
subdomainWorkspace subdomain (for logging/display)
userIdCRM user ID (store if useful)
wasCreated.workspacetrue if a new workspace was created
wasCreated.usertrue if a new CRM user was created
twentyApiKeyPresent 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.

FieldDescription
workspaceUrlOpen this in the browser — complete SSO URL
loginTokenShort-lived token (also embedded in workspaceUrl)
expiresAtToken expiry (ISO 8601)

SSO session behavior:

  • workspaceUrl always includes aventoraSso=1 → CRM hides Log out (user exits via your app).
  • When page is set → URL includes returnToPath (redirect after verify).
  • When embedded: true is also set → URL includes aventoraEmbedded=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}.

MethodPathPurpose
POST/auth/provision/resolveFind or create workspace + user; optional customSettings. Returns twentyApiKey on first workspace create
POST/auth/provision/rotate-api-keyRevoke Partner Provisioning keys and mint a new Admin JWT (twentyApiKey)
POST/auth/provision/login-tokenSSO URL; optional page, embedded, avatar, customSettings
POST/auth/provision/userAdd user to existing workspace
POST/auth/provision/move-userMove user from one workspace to another (access only)
POST/auth/provision/workspaceCreate or reuse named workspace; optional maxContactCount
POST/auth/provision/workspace-contact-limitSet maxContactCount on an existing workspace
GET/auth/provision/workspace?subdomain=Lookup workspace by subdomain

POST /auth/provision/resolve

FieldRequiredDescription
emailYesUser email (lowercased by CRM)
firstNameNoUsed when creating user
lastNameNoUsed when creating user
tenantSubdomainNoShared workspace subdomain; omit for personal workspace
tenantDisplayNameNoDisplay name when creating a new shared workspace
industryPresetNoOnly applied when creating a new workspace — see Industry presets and profiles
industryProfileNoProfile within preset — see Industry presets and profiles
avatarNoPublic http/https image URL — see Profile avatar
customSettingsNoPer-user CRM settings — see Custom settings
maxContactCountNoPositive 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
}
FieldDescription
workspaceIdCRM workspace ID
subdomainWorkspace subdomain
twentyApiKeyNew long-lived Admin-role workspace API key (shown once). Persist server-side; never send to the browser.
revokedKeyCountHow 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

FieldRequiredDescription
workspaceIdYesFrom resolve or your stored setting
emailYesSame email as authenticated Laravel user
firstNameNoUsed when creating user
lastNameNoUsed when creating user
pageNoCRM path for deep-link / embedded mode — see Embedded navigation
embeddedNoWhen true with page, URL includes aventoraEmbedded=1 and CRM hides left nav. Required for chromeless partner embeds.
avatarNoPublic image URL — see Profile avatar
customSettingsNoPer-user CRM settings — see Custom settings
engagementInitiatorPhoneNoLegacy 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.

FieldRequiredDescription
emailYesUser email (lowercased by CRM)
fromWorkspaceIdOne of pairSource workspace UUID
fromWorkspaceSubdomainOne of pairSource workspace subdomain
toWorkspaceIdOne of pairTarget workspace UUID
toWorkspaceSubdomainOne of pairTarget workspace subdomain
firstNameNoUpdates user profile when provided
lastNameNoUpdates user profile when provided
avatarNoPublic image URL for workspace member profile in target workspace
customSettingsNoPer-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:

  1. Update your stored crm_workspace_id (or equivalent) to toWorkspaceId.
  2. Issue the next SSO with login-token using 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 from workspace).
  • 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
}
FieldRequiredDescription
adminEmailYesAdmin user email
subdomainNoWorkspace subdomain
displayNameNoWorkspace display name
adminFirstNameNoUsed when creating admin user
adminLastNameNoUsed when creating admin user
industryPresetNoOnly applied when creating a new workspace — see Industry presets and profiles
industryProfileNoProfile within preset — see Industry presets and profiles
maxContactCountNoPositive 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:

industryPresetindustryProfile valuesDefault profileProfile required?
real_estatebuyer_agent, listing_agent, team_leader, broker, general_realtorgeneral_realtorYes
insurancepersonal_lines, commercial_lines, benefits_advisor, broker_owner, general_insurance_advisorgeneral_insurance_advisorYes
mortgagemortgage_agent, mortgage_broker, commercial_mortgage, team_lead, broker_ownermortgage_agentYes
financial_advisorfinancial_advisor, wealth_advisor, retirement_specialist, team_lead, practice_ownerfinancial_advisorYes
generic (aliases: general, other)defaultdefaultNo
  • For the four industry presets, industryProfile is required — a missing or unknown profile returns HTTP 400.
  • For generic, industryProfile is optional and falls back to default.
  • An unknown industryPreset returns HTTP 400. Legacy onboarding ids general and other resolve to generic.

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):

DeployFRAME_ANCESTORSBrowser framing
Staging / non-partner (default)unset / emptyX-Frame-Options: DENY — iframes blocked
Partner-facing CRMcomma-separated https origins of host appsCSP 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.

Modelogin-token bodyCRM behavior
Full CRMNo pageNormal left nav + mobile bottom nav
Deep link (nav visible)page onlyLands on path; left nav stays visible
Embedded page (chromeless)page and "embedded": trueNav 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

  1. Format: internal path starting with /
    • Valid: /cockpit, /objects/people, /settings/profile
    • Invalid: cockpit, https://..., //evil.com
  2. Validation: invalid paths → HTTP 400 from login-token
  3. 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.
  4. 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 useWhy
A single host menu item labeled Settings with page=/settings/profileLands on Profile only — not a settings hub
page=/settingsNo 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

ScreenpageDefault SSO access
Sales Cockpit/cockpitYes
People (list)/objects/peopleYes
Companies (list)/objects/companiesYes
Opportunities (list)/objects/opportunitiesYes
Tasks (list)/objects/tasksYes
Notes (list)/objects/notesYes
Dashboards (list)/objects/dashboardsYes (if enabled in workspace)
Workflows (list)/objects/workflowsYes (if enabled in workspace)

Pattern: /object/{singularName}/{recordId}

ScreenpageDefault 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:

ScreenpageDefault SSO access
Profile/settings/profileYes — no extra role flags
Experience (theme / locale)/settings/experienceYes — no extra role flags
Connected accounts (hub)/settings/accountsRequires Connected accounts on Aventora User role
Account emails/settings/accounts/emailsRequires Connected accounts
Account calendars/settings/accounts/calendarsRequires 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.

ScreenpageCRM permission flag
Workspace general/settings/generalWORKSPACE
Data model/settings/objectsDATA_MODEL
Members/settings/membersWORKSPACE_MEMBERS
Roles/settings/rolesROLES
Domains/settings/domainsWORKSPACE
Billing/settings/billingWORKSPACE (billing must be enabled)
APIs & Webhooks/settings/api-webhooksAPI_KEYS_AND_WEBHOOKS
Apps/settings/applicationsWORKSPACE (apps feature flag)
AI/settings/aiWORKSPACE (AI feature flag)
Security/settings/securitySECURITY
Updates/settings/updatesWORKSPACE
Admin panel/settings/admin-panelServer 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):

  1. In CRM (as workspace admin): Settings → Roles → Aventora User
  2. Enable the permission flags from the table above (e.g. Connected accounts, Workspace, Members)
  3. 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-token call with a different page, then open the new workspaceUrl.
  • Do not rely on in-CRM navigation when embedded — main nav and settings sidebar are hidden for the SSO session.
  • Use one page per 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"
RuleDetail
Protocolhttp or https only
Max length2048 characters
AccessCRM server must be able to fetch the URL over the public internet
Invalid URLHTTP 400
Unreachable / not an imageProvisioning 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 avatar is provided.
  • Omit avatar to 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.

KeyTypeCRM behavior
engagementInitiatorPhonestringSets 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.

RuleDetail
What countsUser-created and integration-created people
What does not countSystem seed contacts (createdBySource = SYSTEM)
When exceededCreates fail with Too many contacts
Direct DB editsBlocked — 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?

CallCreates workspace?
resolve with new tenantSubdomainYes
resolve without subdomain (no personal WS yet)Yes
login-token with known workspaceIdNever
provision/workspaceYes, if subdomain new

Resolve rules and errors

SituationResult
User already in workspace A; request tenant B via resolve400 — use move-user instead
User in multiple workspaces400 — provisioning channel supports one workspace per user
User exists with no workspace membership400 — orphan; manual CRM cleanup required
User move: only member of source workspace400 on move-user
User move: only admin of source workspace400 on move-user
Unknown workspaceId on login-token404
Inactive workspace on login-token400
Invalid page or avatar on login-token400
Invalid maxContactCount (non-integer or < 1)400
Person create over workspace contact limitToo many contacts (enforced by CRM)
Wrong provisioning secret401
/auth/provision/* returns 404Wrong 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 menulogin-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

  1. Health: curl -sS "{CRM_API_URL}/healthz"
  2. Route exists: POST .../auth/provision/resolve with bad secret → 401 (not 404)
  3. Full SSO: resolve + login-token → open workspaceUrl → user lands in CRM
  4. Embedded: page=/cockpit → no CRM nav, Cockpit visible
  5. Invalid page: page=/welcome400
  6. Avatar: pass public image URL → CRM profile shows picture
  7. Submenu switch: two SSO calls with different page → correct screen each time
  8. Settings — Profile: page=/settings/profile → profile form only (not a settings menu)
  9. Settings — Accounts: page=/settings/accounts → accounts screen (or redirect to Profile if role lacks Connected accounts)
  10. Settings — Experience: page=/settings/experience → theme/locale screen
  11. 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"}'
SymptomLikely cause
404 on provision routesWrong CRM_API_URL, old CRM image, reverse proxy path
401Secret mismatch between Laravel and CRM
400 on resolveTenant conflict, multi-workspace user, orphan user
400 on move-userUser not in source workspace, only member/admin, inactive target
400 on login-tokenInvalid page or avatar, or inactive workspace
Avatar not updatingURL not publicly reachable from CRM server, or not an image
Embedded nav still visiblepage not passed to login-token, or opening CRM without new SSO URL
Settings menu missing in embedded modeExpected — settings sidebar is CRM nav. Add one Laravel menu item per page path (Profile, Accounts, …)
page=/settings/accounts opens ProfileAventora User role lacks Connected accounts — enable in CRM Settings → Roles
Refused to display … X-Frame-Options: denyStaging CRM (expected) or partner CRM missing FRAME_ANCESTORS / nginx forcing DENY — set allowlist on partner deploy only
Google/Microsoft Connect fails only in iframeExpected — open OAuth in a top-level tab/popup; providers block framing
Too many contacts on person createWorkspace at maxContactCount — raise limit via workspace-contact-limit or upgrade tenant plan in Laravel

Changelog

DateChange
2026-08-23Rotate 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-21Admin 2FA: Workspace-owner Admin logins require 2FA unless CRM USE_2FA=false.
2026-08-20Partner workspace API key: first-time resolve returns twentyApiKey. Store it server-side; omit from browser SSO JSON. Official plugin fires CrmWorkspaceProvisioned.
2026-08-11Iframe 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-14Embedded 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-12Added 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-06Added Workspace contact limits: maxContactCount on resolve / workspace, POST /auth/provision/workspace-contact-limit, Laravel examples