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 ascomposer require aventora/crm-laravel
Table of contents
- What you are building
- Configuration
- Security rules
- SSO flow
- Laravel implementation pattern
- API reference
- Embedded navigation (
page) - Profile avatar (
avatar) - Workspace modes
- Resolve rules and errors
- Stored workspace shortcut
- Host app navigation pattern
- Testing checklist
- Troubleshooting
- Changelog
What you are building
Your Laravel app authenticates the user. When they open CRM:
- Backend calls CRM provisioning APIs with a shared secret.
- CRM returns a short-lived
workspaceUrl(one-time login link). - Browser opens that URL (redirect, popup, or iframe).
You never put the provisioning secret in JavaScript. You never build the verify URL yourself — use the workspaceUrl from CRM.
| Concept | Meaning |
|---|---|
| User | Person who logs into CRM (provisioned via /auth/provision/*) |
| Workspace | Tenant/isolated CRM instance ({subdomain}.crm.example.com) |
| Contact | A person record in CRM — created via /rest/people with a workspace API key, not the provisioning secret |
Provisioning and SSO always use the apex CRM API URL (CRM_API_URL). Contact writes use the workspace host — see AVENTORA_CRM.md — Contacts.
Configuration
Add to Laravel .env:
CRM_API_URL=https://crm.example.com
CRM_PROVISIONING_SECRET=your-shared-secret
| Variable | Required | Description |
|---|---|---|
CRM_API_URL | Yes | Apex URL for all /auth/provision/* calls (no trailing slash) |
CRM_PROVISIONING_SECRET | Yes | Must match CRM server PROVISIONING_SECRET |
Optional app settings (store in config or database per tenant):
| Setting | Description |
|---|---|
crm_workspace_id | If already known, skip resolve and call login-token only |
crm_tenant_subdomain | Shared workspace subdomain (omit for personal workspace per user) |
industryPreset / industryProfile | Only when creating a new workspace via resolve |
Example config/crm.php:
<?php
return [
'api_url' => env('CRM_API_URL'),
'provisioning_secret' => env('CRM_PROVISIONING_SECRET'),
'default_tenant_subdomain' => env('CRM_DEFAULT_TENANT_SUBDOMAIN'),
];
Security rules
- Call
/auth/provision/*only from Laravel (controller, job, service) — never from the browser. - Send
Authorization: Bearer {CRM_PROVISIONING_SECRET}on every provisioning request. - Return only the final
workspaceUrl(or a JSON wrapper around it) to the frontend. - Do not expose
CRM_PROVISIONING_SECRETor workspace API keys in client-side code.
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
}
}
| Field | Description |
|---|---|
workspaceId | Use in step 2 |
subdomain | Workspace subdomain (for logging/display) |
userId | CRM user ID (store if useful) |
wasCreated.workspace | true if a new workspace was created |
wasCreated.user | true if a new CRM user was created |
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"
}
| Field | Description |
|---|---|
workspaceUrl | Open this in the browser — complete SSO URL |
loginToken | Short-lived token (also embedded in workspaceUrl) |
expiresAt | Token expiry (ISO 8601) |
SSO session behavior:
workspaceUrlalways includesaventoraSso=1→ CRM hides Log out (user exits via your app).- When
pageis set → URL also includesreturnToPathandaventoraEmbedded=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}.
| Method | Path | Purpose |
|---|---|---|
POST | /auth/provision/resolve | Find or create workspace + user |
POST | /auth/provision/login-token | SSO URL; optional page, avatar |
POST | /auth/provision/user | Add user to existing workspace |
POST | /auth/provision/move-user | Move user from one workspace to another (access only) |
POST | /auth/provision/workspace | Create or reuse named workspace |
GET | /auth/provision/workspace?subdomain= | Lookup workspace by subdomain |
POST /auth/provision/resolve
| Field | Required | Description |
|---|---|---|
email | Yes | User email (lowercased by CRM) |
firstName | No | Used when creating user |
lastName | No | Used when creating user |
tenantSubdomain | No | Shared workspace subdomain; omit for personal workspace |
tenantDisplayName | No | Display name when creating a new shared workspace |
industryPreset | No | Only applied when creating a new workspace |
industryProfile | No | Profile within preset |
avatar | No | Public http/https image URL — see Profile avatar |
POST /auth/provision/login-token
| Field | Required | Description |
|---|---|---|
workspaceId | Yes | From resolve or your stored setting |
email | Yes | Same email as authenticated Laravel user |
firstName | No | Used when creating user |
lastName | No | Used when creating user |
page | No | Embedded CRM path — see Embedded navigation |
avatar | No | Public image URL — see Profile avatar |
engagementInitiatorPhone | No | North 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.
| Field | Required | Description |
|---|---|---|
email | Yes | User email (lowercased by CRM) |
fromWorkspaceId | One of pair | Source workspace UUID |
fromWorkspaceSubdomain | One of pair | Source workspace subdomain |
toWorkspaceId | One of pair | Target workspace UUID |
toWorkspaceSubdomain | One of pair | Target workspace subdomain |
firstName | No | Updates user profile when provided |
lastName | No | Updates user profile when provided |
avatar | No | Public image URL for workspace member profile in target workspace |
{
"email": "agent@example.com",
"fromWorkspaceSubdomain": "wrong-tenant",
"toWorkspaceSubdomain": "correct-tenant",
"firstName": "Alex",
"lastName": "Agent"
}
Response:
{
"userId": "uuid",
"email": "agent@example.com",
"fromWorkspaceId": "uuid",
"fromSubdomain": "wrong-tenant",
"toWorkspaceId": "uuid",
"toSubdomain": "correct-tenant",
"userRestored": false
}
After a successful move:
- Update your stored
crm_workspace_id(or equivalent) totoWorkspaceId. - Issue the next SSO with
login-tokenusing the target workspace ID.
Laravel example:
$result = Crm::moveUser([
'email' => $user->email,
'fromWorkspaceSubdomain' => $oldTenant->crm_subdomain,
'toWorkspaceSubdomain' => $newTenant->crm_subdomain,
'firstName' => $user->first_name,
'lastName' => $user->last_name,
]);
$tenant->update(['crm_workspace_id' => $result['toWorkspaceId']]);
Rules:
- User must belong to exactly one workspace (the
fromworkspace). - Source workspace must have another member (cannot move the sole member out).
- User cannot be the only admin in the source workspace.
- Target workspace must be active.
- Does not migrate People/contacts or engagement history (planned separately).
POST /auth/provision/workspace
Explicitly create a shared workspace (admin onboarding):
{
"adminEmail": "admin@example.com",
"subdomain": "acme",
"displayName": "Acme Insurance",
"adminFirstName": "Admin",
"adminLastName": "User",
"industryPreset": "insurance",
"industryProfile": "general_insurance_advisor"
}
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.
| Mode | page in login-token | CRM behavior |
|---|---|---|
| Full CRM | Omitted | Normal left nav + mobile bottom nav |
| Embedded page | Provided | Nav hidden; user lands on your path |
Rules
- Format: internal path starting with
/- Valid:
/cockpit,/objects/people,/settings/profile - Invalid:
cockpit,https://...,//evil.com
- Valid:
- Validation: invalid paths → HTTP 400 from
login-token - Permissions: valid path may still be empty if the user lacks CRM role permissions
- 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
| Screen | page |
|---|---|
| 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-tokencall with a differentpage, then open the newworkspaceUrl. - 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"
| Rule | Detail |
|---|---|
| Protocol | http or https only |
| Max length | 2048 characters |
| Access | CRM server must be able to fetch the URL over the public internet |
| Invalid URL | HTTP 400 |
| Unreachable / not an image | Provisioning continues; avatar skipped (logged server-side) |
Behavior:
- New member: CRM downloads the image, stores it, sets workspace member profile picture.
- Existing member: CRM re-downloads and updates avatar on each SSO when
avataris provided. - Omit
avatarto leave the current CRM picture unchanged.
Pass your Laravel user's profile photo URL (S3, Gravatar, etc.) on every SSO call to keep CRM in sync.
Workspace modes
Shared tenant workspace
Pass tenantSubdomain on resolve:
{ "email": "user@example.com", "tenantSubdomain": "acme" }
- Subdomain exists → user joins that workspace.
- Subdomain new → CRM creates workspace + user.
Personal workspace
Omit tenantSubdomain:
{ "email": "user@example.com" }
CRM creates or reuses one personal workspace per email.
When is a workspace created?
| Call | Creates workspace? |
|---|---|
resolve with new tenantSubdomain | Yes |
resolve without subdomain (no personal WS yet) | Yes |
login-token with known workspaceId | Never |
provision/workspace | Yes, if subdomain new |
Resolve rules and errors
| Situation | Result |
|---|---|
User already in workspace A; request tenant B via resolve | 400 — use move-user instead |
| User in multiple workspaces | 400 — provisioning channel supports one workspace per user |
| User exists with no workspace membership | 400 — orphan; manual CRM cleanup required |
| User move: only member of source workspace | 400 on move-user |
| User move: only admin of source workspace | 400 on move-user |
Unknown workspaceId on login-token | 404 |
Inactive workspace on login-token | 400 |
Invalid page or avatar on login-token | 400 |
| Wrong provisioning secret | 401 |
/auth/provision/* returns 404 | Wrong host, old CRM deploy, or proxy misconfiguration |
Stored workspace shortcut
If your app already stores crm_workspace_id (e.g. after CRM enablement), skip resolve:
$token = $crmClient->loginToken([
'workspaceId' => $tenant->crm_workspace_id,
'email' => $user->email,
'page' => $page,
'avatar' => $avatar,
]);
return response()->json(['url' => $token['workspaceUrl']]);
login-token never creates a workspace — only user/membership if missing.
Host app navigation pattern
| Your Laravel menu | login-token body |
|---|---|
| Open CRM (full) | omit page |
| 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
- Health:
curl -sS "{CRM_API_URL}/healthz" - Route exists:
POST .../auth/provision/resolvewith bad secret → 401 (not 404) - Full SSO: resolve + login-token → open
workspaceUrl→ user lands in CRM - Embedded:
page=/cockpit→ no CRM nav, Cockpit visible - Invalid page:
page=/welcome→ 400 - Avatar: pass public image URL → CRM profile shows picture
- Submenu switch: two SSO calls with different
page→ correct screen each time - Log out: confirm CRM hides logout; user signs out from Laravel only
Troubleshooting
# Expect 401 (not 404) — route exists, secret wrong
curl -sS -X POST "{CRM_API_URL}/auth/provision/resolve" \
-H "Authorization: Bearer wrong" \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com"}'
# Expect 200 with valid secret
curl -sS -X POST "{CRM_API_URL}/auth/provision/resolve" \
-H "Authorization: Bearer {CRM_PROVISIONING_SECRET}" \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","tenantSubdomain":"demo"}'
| Symptom | Likely cause |
|---|---|
| 404 on provision routes | Wrong CRM_API_URL, old CRM image, reverse proxy path |
| 401 | Secret mismatch between Laravel and CRM |
| 400 on resolve | Tenant conflict, multi-workspace user, orphan user |
| 400 on move-user | User not in source workspace, only member/admin, inactive target |
| 400 on login-token | Invalid page or avatar, or inactive workspace |
| Avatar not updating | URL not publicly reachable from CRM server, or not an image |
| Embedded nav still visible | page not passed to login-token, or opening CRM without new SSO URL |
Changelog
Integration-relevant changes (newest first). Full CRM changelog: AVENTORA_CRM.md — Changelog.
| Date | Change |
|---|---|
| 2026-06-14 | move-user: POST /auth/provision/move-user moves workspace membership between tenants (access only; CRM data not migrated). |
| 2026-06-13 | Avatar: optional avatar on resolve, login-token, and provision/user. CRM downloads public image URL and sets workspace member profile picture. |
| 2026-06-13 | Embedded 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-13 | SSO session UX: workspaceUrl includes aventoraSso=1; CRM hides Log out for provisioned SSO sessions. |
| 2026-06-09 | Industry preset workflow templates and Sales Cockpit automation rules (workspace creation only). |
| 2026-06-09 | Consolidated provisioning docs into Aventora CRM guide. |
When updating CRM provisioning or SSO behavior, update this file and the Changelog section in the same PR.