API
Overview
Laravel CRM ships with a JSON REST API for partner developers and external integrations. The API is versioned, authenticated with Laravel Sanctum personal access tokens, and mounted at /crm/api/v2.
- Base URL:
https://your-app.test/crm/api/v2 - Content type:
application/json - Authentication: Sanctum bearer tokens
- Current version:
v2
The API exposes 8 resourceful entities with full CRUD (list, create, show, update, soft-delete), plus 3 auth endpoints for issuing, inspecting, and revoking tokens.
Entity references
Each entity has its own reference page covering endpoints, list parameters, JSON shape, and writable fields:
Requirements
- Laravel CRM installed and migrated.
- Laravel Sanctum installed in the host application.
- A CRM user with
crm_accessgranted, to issue tokens against.
Installation
The API ships with the package — you only need to wire Sanctum into the host application.
1. Publish and run Sanctum migrations
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate
This creates the personal_access_tokens table.
2. Add HasApiTokens to your User model
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens;
// ...
}
3. Verify the API routes are registered
php artisan route:list --path=crm/api
You should see 8 resourceful entities (5 verbs each), plus the 3 auth routes.
Authentication
The API uses Sanctum personal access tokens. Tokens can be issued through the API itself, or via an artisan command for ops use.
Issue a token via the API
POST /crm/api/v2/auth/token
Content-Type: application/json
{
"email": "user@example.com",
"password": "secret",
"device_name": "Mobile App"
}
Response (201 Created):
{
"token": "1|abcdef1234...",
"user": {
"id": 1,
"name": "Jane Doe",
"email": "user@example.com"
}
}
- Returns
422on bad credentials, an unknown email, or when the user lackscrm_access. The response is intentionally indistinguishable across these cases to avoid leaking which emails belong to real users. device_nameis optional and defaults to the request'sUser-Agentorapi-token.- The plaintext token is shown once — store it securely.
Issue a token via artisan
php artisan laravel-crm:api-token user@example.com --name="Mobile App"
The plaintext token is printed once. The command exits non-zero if the user does not exist or lacks crm_access.
Note:
laravel-crm:api-tokenis hyphenated. It is the one exception — every other artisan command this package ships is namespacedlaravelcrm:with no hyphen (laravelcrm:install,laravelcrm:update,laravelcrm:upgrade). This is not a typo in the docs.
Making authenticated requests
Pass the token in the Authorization header:
GET /crm/api/v2/leads HTTP/1.1
Authorization: Bearer 1|abcdef1234...
Accept: application/json
Inspecting the current user
GET /crm/api/v2/auth/me
Response (200 OK):
{
"user": {
"id": 1,
"name": "Jane Doe",
"email": "user@example.com"
}
}
Revoking the current token
DELETE /crm/api/v2/auth/token
Returns 204 No Content and deletes the personal access token used to authenticate the request.
Headers
| Header | Required | Purpose |
|---|---|---|
Authorization: Bearer <token> |
Yes (except POST /auth/token) |
Sanctum personal access token. |
Accept: application/json |
Recommended | Forces JSON responses (the laravel-crm.api.json middleware sets this automatically when missing). |
Content-Type: application/json |
Yes (for POST / PUT) |
Request body is JSON. |
X-Team-ID: <team-id> |
Optional | Overrides the authenticated user's active team for the request. Must be a team the user belongs to; otherwise the API returns 403. Only relevant when laravel-crm.teams=true. |
Multi-tenancy notes
When the host app runs in teams mode (config('laravel-crm.teams', false) — teams are off by default):
- Without
X-Team-ID, requests are scoped to the user'scurrent_team_id. - With
X-Team-ID, list / store / update / delete endpoints run in the context of that team. GET /{resource}/{uuid}resolves the route-bound model using the user's default current team, because Laravel'sSubstituteBindingsmiddleware runs before the team-context middleware. Use the list endpoints (filtered byX-Team-ID) to discover the correct UUIDs for the active team.- A token whose user has no current team cannot reference anything. Every foreign key on a write whose table is team-scoped —
person_id,organization_id,pipeline_stage_id,labels[],line_items.*.product_id, and the rest — is validated against the active team. When teams are on and the token's user has nocurrentTeam, and none was supplied viaX-Team-ID, the rule matches nothing rather than falling back to unscoped, so every such id comes back422at once. It presents as "all my ids are suddenly invalid". The fix is on the user record, not the payload: give the user a current team, or sendX-Team-ID. Service accounts created outside the host app's normal registration flow are the usual cause.
Endpoint summary
Auth
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/crm/api/v2/auth/token |
Public | Issue a personal access token. |
GET |
/crm/api/v2/auth/me |
Bearer | Return the authenticated user. |
DELETE |
/crm/api/v2/auth/token |
Bearer | Revoke the current token. |
Entities
All entity endpoints follow the same RESTful shape:
| Verb | Path | Action |
|---|---|---|
GET |
/{resource} |
List (paginated). |
POST |
/{resource} |
Create. |
GET |
/{resource}/{uuid} |
Show. |
PUT |
/{resource}/{uuid} |
Update. |
DELETE |
/{resource}/{uuid} |
Soft-delete. |
{uuid} is the entity's external_id (UUID), exposed as id in JSON responses.
| Resource | Path | Reference |
|---|---|---|
| Lead | /crm/api/v2/leads |
Leads |
| Product | /crm/api/v2/products |
Products |
| Organization | /crm/api/v2/organizations |
Organizations |
| Person | /crm/api/v2/people |
People |
| Deal | /crm/api/v2/deals |
Deals |
| Quote | /crm/api/v2/quotes |
Quotes |
| Order | /crm/api/v2/orders |
Orders |
| Invoice | /crm/api/v2/invoices |
Invoices |
Conventions
- IDs are UUIDs. The JSON
idis always the entity'sexternal_id. Integer primary keys are never exposed. - Foreign keys are also UUIDs for CRM entities — e.g.
person_id,organization_id,lead_id,pipeline_stage_id,lead_source_id,product_category_id, and everylabels[]entry must be a UUID (the related row'sexternal_id). - Some lookups are integer IDs. Host-app users (
user_owner_id) and a handful of small reference tables that aren't UUID-backed use integer primary keys:tax_rate_id,organization_type_id,industry_id,timezone_id. - Human-readable IDs are returned, not accepted. Entities also expose a sequential identifier (
lead_id,deal_id,quote_id,order_id,invoice_id, e.g.L1001,D1001) in the response. These are read-only and assigned automatically. - Money is dollars in JSON; cents in storage. All amount / price / total / subtotal / discount / tax / adjustments fields are sent and returned as decimal dollars (e.g.
1500.50). The package converts to integer cents on write. - Timestamps are ISO-8601 and must include a timezone offset or the
ZUTC suffix on input — e.g.2026-07-15T10:00:00+00:00or2026-07-15T10:00:00Z. Other formats are rejected with a422. - Pagination:
?per_page=N(1–100, default 25). Responses use Laravel's standard pagination envelope (data,meta,links). - Sorting:
?sort=fieldascending;?sort=-fielddescending. Unknown columns are silently ignored. Default sort is-created_at. - Soft deletes:
DELETEreturns204and soft-deletes the row. SubsequentGETs return404. subtotalandtotalare computed and rejected on input. On quotes and orders both are derived fromline_items,discount,taxandadjustments; on invoices, which carry nodiscountoradjustmentsfield, fromline_itemsandtax. Sending either on aPOSTorPUTis a422naming the cause, rather than being silently ignored and recomputed. An absent ornullvalue passes, so a payload that never sent them is unaffected. Both are still returned in responses.discountandtaxreject negatives.taxcarriesmin:0on quote / order / invoice writes;discountcarries it on quote / order writes, invoices having no such field. A negative discount was a way to inflate a total past the sum of the line items; on a quote or order, send a negativeadjustmentsvalue instead.- Referenced ids must belong to the active team. Every UUID reference on a write is checked against the request's team as well as the table. An id belonging to another team is a
422on that field, where it previously validated and produced a cross-team record. Single-tenant installs (laravel-crm.teams = false) are unaffected — the check is skipped entirely — as are package-wide lookup tables that carry noteam_idcolumn, which are still checked against the table alone.
Errors
The API returns standard Laravel error envelopes.
422 Unprocessable Entity (validation)
{
"message": "The given data was invalid.",
"errors": {
"title": ["The title field is required."],
"person_id": ["The selected person_id is invalid."]
}
}
401 Unauthorized
{ "message": "Unauthenticated." }
403 Forbidden
{ "message": "This action is unauthorized." }
For the X-Team-ID non-member case:
{ "message": "You are not a member of the requested team." }
404 Not Found
Returned when a UUID does not resolve to a model (or it has been soft-deleted).
429 Too Many Requests
Returned when the rate limit is exceeded. Standard Laravel rate-limit headers are included: X-RateLimit-Limit, X-RateLimit-Remaining, and Retry-After.
Rate limits
The API enforces a single named rate limiter, laravel-crm-api:
| Caller | Limit |
|---|---|
| Authenticated (Sanctum) | 60 requests / minute / user |
| Unauthenticated | 30 requests / minute / IP |
Exceeding the limit returns 429 Too Many Requests with Retry-After in seconds.
POST /auth/token is throttled twice
On top of the per-IP limit above, token issuing carries a fixed throttle:6,1 (6 attempts / minute / IP) and a per-account counter on failed attempts, so credential stuffing spread across many IPs against one email address does not get unlimited attempts.
| Setting | Default | Environment Variable |
|---|---|---|
laravel-crm.api.token_attempts_per_account |
5 |
LARAVEL_CRM_API_TOKEN_ATTEMPTS_PER_ACCOUNT |
laravel-crm.api.token_attempts_decay_seconds |
600 |
LARAVEL_CRM_API_TOKEN_ATTEMPTS_DECAY_SECONDS |
The per-account counter is keyed on the submitted email, incremented only on a failed attempt, and cleared on success. Once it trips, the endpoint returns 429 with the error under errors.email rather than the usual 429 envelope:
{
"message": "Too many login attempts. Try again in 540 seconds.",
"errors": { "email": ["Too many login attempts. Try again in 540 seconds."] }
}
Important:
POST /auth/tokencould not return429before 2.4.0. A client that re-issues a token on every request, or retries on failure without backing off, will start seeing it — issue a token once and reuse it, and back off on429. Both limits key on IP or email, so one misbehaving integration can lock out a shared address; raise the two config keys if your deployment legitimately issues tokens in bursts. See Configuration → API.
Worked example
Issue a token, list leads, create a lead, then revoke the token.
# 1. Issue a token
curl -s -X POST https://example.test/crm/api/v2/auth/token \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"secret","device_name":"curl"}' \
| jq .
# 2. List leads
TOKEN="1|abcdef..."
curl -s https://example.test/crm/api/v2/leads \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json" \
| jq .
# 3. Create a lead
curl -s -X POST https://example.test/crm/api/v2/leads \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "New enterprise lead",
"amount": 12500.00,
"currency": "USD",
"expected_close": "2026-09-30T00:00:00Z"
}' \
| jq .
# 4. Revoke the token
curl -s -X DELETE https://example.test/crm/api/v2/auth/token \
-H "Authorization: Bearer $TOKEN" \
-i