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:

  • AVENTORA_CRM.md — full CRM operator guide (deployment, presets, Sales Cockpit, TIPS, contacts API)
  • API_SECURITY_MODEL.md — platform security overview
  • Official plugin (optional): aventora-crm/laravel-plugin/ — same API, packaged as composer require aventora/crm-laravel

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)
  8. Profile avatar (avatar)
  9. Workspace modes
  10. Resolve rules and errors
  11. Stored workspace shortcut
  12. Host app navigation pattern
  13. Testing checklist
  14. Troubleshooting
  15. Changelog

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 AVENTORA_CRM.md — Contacts.


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_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.

SSO flow

sequenceDiagram
participant Browser
participant Laravel
participant CRM as Aventora CRM

Browser->>Laravel: GET /your-app/open-crm?page=/objects/people
Laravel->>CRM: POST /auth/provision/resolve
CRM-->>Laravel: workspaceId, userId, subdomain
Laravel->>CRM: POST /auth/provision/login-token
CRM-->>Laravel: workspaceUrl, loginToken, expiresAt
Laravel-->>Browser: JSON { url } or redirect
Browser->>CRM: Open workspaceUrl
CRM-->>Browser: Authenticated CRM session

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"
}

Response:

{
"workspaceId": "uuid",
"subdomain": "acme",
"userId": "uuid",
"wasCreated": {
"workspace": false,
"user": true
}
}
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

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",
"avatar": "https://cdn.example.com/avatars/agent.png",
"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"
}
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 also includes returnToPath and aventoraEmbedded=1 → CRM hides left nav and mobile bottom nav.

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 openCrm(
string $email,
?string $firstName = null,
?string $lastName = null,
?string $tenantSubdomain = null,
?string $page = null,
?string $avatar = null,
): array {
$resolvePayload = array_filter([
'email' => $email,
'firstName' => $firstName,
'lastName' => $lastName,
'tenantSubdomain' => $tenantSubdomain,
'avatar' => $avatar,
], fn ($value) => $value !== null);

$resolved = $this->resolve($resolvePayload);

$loginPayload = array_filter([
'workspaceId' => $resolved['workspaceId'],
'email' => $email,
'firstName' => $firstName,
'lastName' => $lastName,
'page' => $page,
'avatar' => $avatar,
], 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,
];
}

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

$result = $crm->openCrm(
email: $user->email,
firstName: $user->first_name,
lastName: $user->last_name,
tenantSubdomain: config('crm.default_tenant_subdomain'),
page: $page,
avatar: $avatar,
);

return response()->json($result);
})->middleware('auth');

Frontend opens result.url in an iframe, popup, or full redirect.


API reference

All routes: Authorization: Bearer {CRM_PROVISIONING_SECRET} on {CRM_API_URL}.

MethodPathPurpose
POST/auth/provision/resolveFind or create workspace + user
POST/auth/provision/login-tokenSSO URL; optional page, avatar
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
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
industryProfileNoProfile within preset
avatarNoPublic http/https image URL — see Profile avatar

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
pageNoEmbedded CRM path — see Embedded navigation
avatarNoPublic image URL — see Profile avatar
engagementInitiatorPhoneNoNorth American phone; stored on membership for Sales Cockpit if 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"
}

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
{
"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"
}

Idempotent: existing subdomain returns the workspace and ensures admin user membership.


Embedded navigation (page)

Use when your Laravel app owns navigation (sidebar, submenu) and CRM opens in an iframe or panel.

Modepage in login-tokenCRM behavior
Full CRMOmittedNormal left nav + mobile bottom nav
Embedded pageProvidedNav hidden; user lands on your path

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. Permissions: valid path may still be empty if the user lacks CRM role permissions
  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)

Common page values

Screenpage
Sales Cockpit/cockpit
People list/objects/people
Companies list/objects/companies
Opportunities list/objects/opportunities
Tasks list/objects/tasks
User profile settings/settings/profile
Person record/object/person/{uuid}
Company record/object/company/{uuid}

Full list: AVENTORA_CRM.md — Available page values.

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 — nav is hidden for the SSO session.
  • 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.


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
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
Sales Cockpit"page": "/cockpit"
People"page": "/objects/people"
Companies"page": "/objects/companies"
My CRM profile"page": "/settings/profile"

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. 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

Changelog

Integration-relevant changes (newest first). Full CRM changelog: AVENTORA_CRM.md — Changelog.

DateChange
2026-06-14move-user: POST /auth/provision/move-user moves workspace membership between tenants (access only; CRM data not migrated).
2026-06-13Avatar: optional avatar on resolve, login-token, and provision/user. CRM downloads public image URL and sets workspace member profile picture.
2026-06-13Embedded navigation: optional page on login-token. workspaceUrl includes returnToPath + aventoraEmbedded=1; CRM hides nav. Host app drives navigation via new SSO per menu item.
2026-06-13SSO session UX: workspaceUrl includes aventoraSso=1; CRM hides Log out for provisioned SSO sessions.
2026-06-09Industry preset workflow templates and Sales Cockpit automation rules (workspace creation only).
2026-06-09Consolidated provisioning docs into Aventora CRM guide.

When updating CRM provisioning or SSO behavior, update this file and the Changelog section in the same PR.