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 and mints one login-token.
  2. CRM returns loginToken, expiresAt, workspaceOrigin, and workspaceUrl.
  3. Browser opens workspaceUrl once, then uses postMessage or a rewritten returnToPath for later menu clicks until expiresAt.

You never put the provisioning secret in JavaScript. Mint login-token on the server, then send loginToken, expiresAt, workspaceOrigin, and workspaceUrl to the browser. You may change returnToPath on that URL with the same loginToken until expiresAt. Do not mint a new token on every menu click.

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 loginToken, expiresAt, workspaceOrigin, and workspaceUrl (or url) to the frontend. Never send CRM_PROVISIONING_SECRET or workspace API keys to the browser.
  • 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",
"workspaceOrigin": "https://acme.crm.example.com",
"workspaceUrl": "https://acme.crm.example.com/verify?loginToken=...&aventoraSso=1&aventoraEmbedded=1&returnToPath=%2Fobjects%2Fpeople"
}

Pass "embedded": true with page when the host needs chromeless CRM (iframe). Omit embedded for new-tab deep links so left navigation remains visible.

The JWT is identity (email + workspace), not a page ticket. page is only returnToPath on workspaceUrl. Partners may change returnToPath on that URL with the same loginToken until expiresAt. Path rules are the same as page (/ prefix, not //, not auth/onboarding). After the first successful /verify, CRM session cookies keep the user in; do not mint a new token for every menu click.

FieldDescription
workspaceUrlComplete SSO URL for first load (or when the iframe has no CRM session)
workspaceOriginCRM origin for iframe postMessage and for rewriting returnToPath
loginTokenReusable until expiresAt (also embedded in workspaceUrl)
expiresAtToken expiry (ISO 8601). Mint again only after this.

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'],
'workspaceOrigin' => $token['workspaceOrigin'] ?? null,
'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; sets/overwrites membership callback number (omitted or empty does not clear)

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, Sales Cockpit rules, and Custom Start Aventora defaults (channel, type, instruction). 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.

Partner-authored JSON files are ops CLI only (workspace:apply-industry-preset --from-file). They are not industryPreset values on resolve, workspace, or demo-tenant, and they do not appear in GET /auth/provision/industry-preset-catalog. Pass a catalog industry id on provision; apply a custom file later from the server CLI.

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
reable_real_estate (alias: reable-real-estate)defaultdefaultNo
  • For the four industry presets, industryProfile is required — a missing or unknown profile returns HTTP 400.
  • For generic and reable_real_estate, 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. After the first SSO, switching those items uses postMessage or a rewritten returnToPath — not a new login-token.

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 (postMessage or rewritten returnToPath, same token)

See also: CRM Complete Guide — Available page values for the operator reference (same paths).

Important​

  • Call login-token once when the user opens CRM. Cache { loginToken, expiresAt, workspaceOrigin } until expiresAt.
  • Menu click while the iframe is already logged in: postMessage { type: 'aventora.crm.navigate', page } to workspaceOrigin. No new token. No /verify.
  • If the iframe is gone but the token is still valid: set iframe.src to {workspaceOrigin}{page} (session cookies). If that sends the user to sign-in, open /verify with the cached token and a new returnToPath.
  • Mint a new login-token only when expiresAt has passed (or there is no cached token).
  • In-CRM links (People list → a person) already work and stay chromeless. Host menu is for section switches.
  • 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/overwrites userWorkspace.aventoraEngagementInitiatorPhone (North American 10-digit normalization). Omitted or empty leaves the stored value unchanged. Users can still edit Settings → Profile → Engagement callback number; a later partner provision with this key overwrites that value.
"customSettings": {
"engagementInitiatorPhone": "6473710396"
}

To sync a number change from the partner platform, POST /auth/provision/user with workspaceId, email, and customSettings.engagementInitiatorPhone. Use PROVISIONING_SECRET. Do not use the workspace API key.

Official plugin:

Crm::provisionUser($workspaceId, $email, customSettings: [
'engagementInitiatorPhone' => $phone,
]);

If you send this key on every SSO (openCrm / login-token), the partner number wins over a number the user typed in CRM Profile.

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. Same overwrite rules apply.


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. Cache loginToken / expiresAt / workspaceOrigin and reuse returnToPath until expiry, same as Host app navigation pattern.


Host app navigation pattern​

Call login-token (or GET /crm/sso) once. Cache the result until expiresAt. Menu items then navigate inside that session.

Your Laravel menupage (first SSO or rewritten returnToPath)
Open CRM (full)omit page and embedded
Sales Cockpit/cockpit
People/objects/people
Companies/objects/companies
Profile/settings/profile
Experience/settings/experience
Connected accounts/settings/accounts
Account emails/settings/accounts/emails
Account calendars/settings/accounts/calendars

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.

The official Laravel plugin caches the CRM login token per user + workspace. GET /crm/sso?page=/objects/people then ?page=/objects/companies (before expiry) is one CRM login-token call; the plugin rewrites returnToPath.

Example frontend (SSO on first open or after expiry; menu clicks stay in-session):

let sso = null; // { loginToken, expiresAt, workspaceOrigin, embedded }

function isTokenValid() {
return sso && Date.parse(sso.expiresAt) > Date.now();
}

async function ensureSso(page) {
if (isTokenValid()) {
return sso;
}
const params = page ? `?page=${encodeURIComponent(page)}` : '';
const response = await fetch(`/crm/sso${params}`, { credentials: 'include' });
sso = await response.json(); // loginToken, expiresAt, workspaceOrigin, url
return sso;
}

function verifyUrl(page) {
const url = new URL('/verify', sso.workspaceOrigin);
url.searchParams.set('loginToken', sso.loginToken);
url.searchParams.set('aventoraSso', '1');
if (sso.embedded !== false) url.searchParams.set('aventoraEmbedded', '1');
if (page) url.searchParams.set('returnToPath', page);
return url.toString();
}

async function openCrmPage(page) {
const iframe = document.getElementById('crm-iframe');
await ensureSso(page);

if (iframe?.contentWindow) {
iframe.contentWindow.postMessage(
{ type: 'aventora.crm.navigate', page },
sso.workspaceOrigin,
);
return;
}

if (iframe) {
iframe.src = page ? `${sso.workspaceOrigin}${page}` : verifyUrl(page);
return;
}

window.location.href = verifyUrl(page);
}

CRM accepts postMessage navigate only in embedded SSO (aventoraEmbedded=1), only from FRAME_ANCESTORS origins, and only for valid page paths. Keep the provisioning secret on the server.


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=/welcome → 400
  6. Avatar: pass public image URL → CRM profile shows picture
  7. Submenu switch: one login-token, then People → Companies via postMessage (or rewritten returnToPath) → correct screen, no extra token mint, no session reset
  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 visibleembedded: true missing on first login-token, or opening CRM without aventoraEmbedded=1
Submenu click remints a token / flashes loginHost is calling login-token per click instead of postMessage / cached returnToPath
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-09-24Bulk email billing: POST /rest/aventora/start-engagement-bulk with channel: "email" records an outbound-email usage row in Engagement Hub when each row is accepted.
2026-09-21Bulk email From name: POST /rest/aventora/start-engagement-bulk with workspace API key uses the default initiator CRM user account name as From. Laravel htmlBody is accepted as emailBodyHtml.
2026-09-17Partner preset JSON is CLI-only: Custom industry files (workspace:apply-industry-preset --from-file) are not accepted as industryPreset on resolve / workspace / demo-tenant. Provision with a catalog id; apply partner JSON from the CRM server CLI.
2026-09-17Industry preset Engage defaults: Creating a workspace with a preset, or workspace:reapply-industry-preset, sets Custom Start Aventora channel / type / instruction (AVENTORA_DEFAULT_*). Reapply overwrites those three Settings → Apps values.
2026-09-12Engagement callback overwrite: customSettings.engagementInitiatorPhone overwrites the CRM callback number. Omitted or empty does not clear. Sync a partner phone change with Crm::provisionUser(..., customSettings: ['engagementInitiatorPhone' => $phone]) and PROVISIONING_SECRET — not the workspace API key. Sending the key on every openCrm SSO wins over a number typed in Settings → Profile.
2026-09-12Reusable SSO token: login-token is identity, not a page ticket. Response includes workspaceOrigin. Call once, cache until expiresAt, then postMessage { type: 'aventora.crm.navigate', page } or rewrite returnToPath. Official plugin caches per user+workspace. /verify no longer wipes a matching CRM session.
2026-09-09Reable Real Estate v1.1.0: Preset adds Home valuation lead source and DRAFT portal/valuation plus floating-holiday workflows. Existing Reable workspaces need workspace:reapply-industry-preset.
2026-09-09Reable Real Estate preset: Catalog includes reable_real_estate (alias reable-real-estate). Profile default is optional.
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