Tenant API v1 — Companies
Base path: /api/tenant/v1
Middleware: auth:tenant-api, tenant.user, verified, module:companies, plus permission middleware / policies.
Assignee scoping: without companies.assign (and not superadmin), list/stats/view/update only include companies where assigned_to is the current user.
Stats
GET /companies/stats
Same filters as list (minus pagination/sort). Payload includes:
total_companies, my_companies, unassigned, with_email, created_this_week, todays_follow_ups, overdue_follow_ups, scope (org|mine).
Companies CRUD
GET /companies
Query: search, industry, assigned_to (unassigned or user id), my_companies, trashed, sort, direction, page, per_page.
List items include latest_note — most recent note (id, body, author, timestamps) or null, next_follow_up_at (denormalized due timestamp for the earliest pending follow-up), and next_follow_up (the full follow-up object, or null). May include contacts_count when counted. possible_duplicates_count is how many other active companies share normalized_name.
POST /companies
Body: name (required), email, phone, website, industry, address, source, source_meta, assigned_to.
GET /companies/{id}
Includes assignee, creator, notes, activities, follow-ups, and linked contacts (when loaded). Embedded notes and activities are newest-first (created_at DESC, then id DESC). Embedded follow_ups keep product scheduling order (not reversed as a chat feed).
PUT /companies/{id}
Partial update of company fields (including assigned_to).
DELETE /companies/{id}
Soft delete. Permission: companies.delete.
POST /companies/{id}/merge-preview
Permission: companies.delete.
Body: { "duplicate_ids": [number, ...] }
Returns the primary, the duplicates found in this workspace, moves (key, label, count for contacts, notes, follow-ups, activity, deals, leads, billing documents, projects, tickets, documents, emails, and calendar events), and fills (empty primary fields that will be copied).
POST /companies/{id}/merge
Same body and permission. Runs in one database transaction: reassigns related records to the primary, fills only empty profile fields, writes a merged activity (“Merged from …”), and soft-deletes the duplicates. A failure rolls the transaction back. Another workspace’s company id is rejected with 422.
POST /companies/{id}/restore
Restore a soft-deleted company. Permission: companies.restore.
DELETE /companies/{id}/force
Permanently delete a soft-deleted company (must already be trashed). Permission: companies.force.delete (owner by default).
Actions
POST /companies/{id}/assign
{ "assigned_to": number|null }
POST /companies/{id}/notes
{ "body": string }
GET /companies/{id}/timeline
Company activity timeline entries.
Follow-ups (1.4.0)
Same shape as Contacts follow-ups, scoped by company_id. Permission: companies.update for all follow-up routes.
POST /companies/{id}/follow-ups—{ "title", "due_at", "notes"?, "assigned_to"? }PUT /companies/{id}/follow-ups/{followUpId}— partial update; changingdue_atreschedulesPOST /companies/{id}/follow-ups/{followUpId}/complete— marks completed
Creating/updating/completing recomputes companies.next_follow_up_at (earliest pending due_at) and, when Calendar is entitled, upserts/clears a company calendar projection. Due/overdue reminders (workspace-local "today") are sent by the daily crm:send-due-notifications job to the follow-up's assigned_to (falling back to the company's assignee).
Import / Export (1.4.0)
Same shape as Contacts import/export. Permission for export: companies.export. Permission for all import routes: companies.import (+ module:companies). Duplicate mode update also requires companies.update.
GET /companies/export?format=csv|xlsx— id, name, email, phone, website, industry, source, assignee, creator, contacts count, next follow-up, created atGET /companies/import/template?format=csv|xlsxGET/POST /companies/imports,GET/PUT /companies/imports/{uuid},PUT /companies/imports/{uuid}/options,POST /companies/imports/{uuid}/preview,POST /companies/imports/{uuid}/runGET /companies/imports/{uuid}/file,/failed-records,/error-report
Mappable fields: name (required), email, phone, website, industry, source, assigned_to. Options body: { "unique_fields": ["email","phone"], "duplicate_mode": "skip"|"update"|"keep" } — no assignment_mode (no equal-distribute; assigned_to column or manual-create default only). Queued via ProcessCompanyImportJob on the imports queue.
Billing summary & statement
Same shape as Contacts billing summary & statement, scoped by company_id:
GET /companies/{id}/billing-summaryGET /companies/{id}/statement?from=&to=(includesopening_balance+balance_due)GET /companies/{id}/statement.pdf?from=&to=
Contact linkage
Contact create/update (see tenant-v1-contacts.md) accept optional company_id. When set, the contact’s legacy company string is synced to the linked Company name. List/detail contact payloads may include company_id and linked_company (id, uuid, name).