Business AI OS — Integration API v1
Push customers, jobs, and invoices from your external system into a Business AI OS company (tenant). Data is upserted idempotently and becomes available to the mobile app and AI assistant.
Version: v1
Base path: /api/integrations/v1
Content type: application/json
Direction: Inbound only (your system → Business AI OS)
Overview
| Concept | Description |
|---|---|
| Tenant | One company in Business AI OS. Each API key belongs to exactly one tenant. |
| Upsert | Create if new, update if the same record is sent again. |
| External ID | Your stable identifier for a record in your system. Used with external_source for matching. |
| Payload | Entity-specific fields (name, title, amount, etc.). |
Supported entities:
customersjobsinvoices
Authentication
All endpoints require a Bearer token (integration API key).
Authorization: Bearer bai_xxxxxxxxxxxxxxxx
Accept: application/json
Obtaining an API key
- Sign in to the platform admin portal:
/admin - Open the target company (tenant)
- Scroll to Integration API keys → Create API key
- Copy the key immediately — it is shown once and cannot be retrieved later
Key format: bai_ followed by 48 random characters.
Scopes
| Scope | Access |
|---|---|
write |
POST /sync, POST /sync/batch (default for new keys) |
read |
Reserved for future read endpoints |
Keys without write scope receive 403 on sync endpoints.
Security notes
- Store keys in environment variables or a secrets manager — never in client-side code or public repos.
- Revoke compromised keys in
/admin→ company → Revoke. - Each key is tied to one tenant; data is written only to that tenant’s database.
Base URL
| Environment | Example |
|---|---|
| Local | http://127.0.0.1:8001/api/integrations/v1 |
| Production | https://your-host.example.com/api/integrations/v1 |
All paths below are relative to this base URL.
Rate limiting
- 120 requests per minute per IP (Laravel throttle middleware)
- Batch endpoint: up to 500 records per request (counts as one request)
When exceeded, the API returns 429 Too Many Requests.
Idempotency
Send the same record again to update it instead of creating a duplicate.
Matching order:
external_source+external_id(recommended)- Fallback heuristics if
external_idis omitted:- customers: email, else name
- jobs:
reference, else title - invoices:
number, elseexternal_idas number
Recommended external ID pattern
Use stable IDs from your system:
your-system-customer-{id}
your-system-job-{id}
your-system-invoice-{id}
Set external_source to a short system name (e.g. fieldops, xero, erp). If omitted, the API key’s name is used as the source.
Endpoints
GET /health
Verify authentication and which tenant the key belongs to.
Authentication: Required
Scope: Any valid key
Response 200 OK
{
"status": "ok",
"tenant": {
"id": "demo-cooltech",
"name": "CoolTech HVAC"
},
"key": {
"name": "fieldops production",
"prefix": "bai_abc123456789",
"scopes": ["write"]
}
}
Example
curl -s "https://your-host.example.com/api/integrations/v1/health" \
-H "Authorization: Bearer bai_YOUR_KEY" \
-H "Accept: application/json"
POST /sync
Upsert one record.
Authentication: Required
Scope: write
Request body
| Field | Type | Required | Description |
|---|---|---|---|
entity |
string | Yes | One of: customers, jobs, invoices |
payload |
object | Yes | Entity fields (see Payload reference) |
external_id |
string | No | Your record ID (max 255 chars). Strongly recommended. |
external_source |
string | No | System identifier (max 64 chars). Defaults to API key name. |
Response 200 OK
{
"success": true,
"created": 1,
"updated": 0,
"skipped": 0
}
| Field | Description |
|---|---|
created |
New rows inserted in this request |
updated |
Existing rows updated |
skipped |
Records ignored (e.g. missing required payload fields) |
Example — customer
curl -s -X POST "https://your-host.example.com/api/integrations/v1/sync" \
-H "Authorization: Bearer bai_YOUR_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"entity": "customers",
"external_id": "erp-customer-42",
"external_source": "my_erp",
"payload": {
"name": "Siti Rahman",
"phone": "0170000000",
"email": "siti@example.com",
"address": "Petaling Jaya, Selangor"
}
}'
POST /sync/batch
Upsert up to 500 records in one request.
Authentication: Required
Scope: write
Request body
| Field | Type | Required | Description |
|---|---|---|---|
records |
array | Yes | 1–500 sync records |
Each element in records has the same shape as the /sync body:
| Field | Type | Required |
|---|---|---|
entity |
string | Yes |
payload |
object | Yes |
external_id |
string | No |
external_source |
string | No |
Response 200 OK
{
"success": true,
"processed": 2,
"created": 2,
"updated": 0,
"skipped": 0
}
Example
curl -s -X POST "https://your-host.example.com/api/integrations/v1/sync/batch" \
-H "Authorization: Bearer bai_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"records": [
{
"entity": "customers",
"external_id": "erp-customer-1",
"external_source": "my_erp",
"payload": { "name": "ACME Corp", "phone": "0312345678" }
},
{
"entity": "jobs",
"external_id": "erp-job-99",
"external_source": "my_erp",
"payload": {
"title": "Compressor replacement",
"status": "completed",
"amount": 900,
"customer_external_id": "erp-customer-1"
}
}
]
}'
Tip: Sync customers before jobs/invoices that reference customer_external_id.
Payload reference
Fields inside the payload object. Types reflect what the importer accepts.
Customers (entity: "customers")
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | Customer or company name. Records without name are skipped. |
phone |
string | No | Phone number |
email |
string | No | Email address |
address |
string | No | Free-text address |
Jobs (entity: "jobs")
| Field | Type | Required | Description |
|---|---|---|---|
title |
string | Yes | Job title. Records without title are skipped. |
status |
string | No | e.g. pending, assigned, in_progress, completed, cancelled. Default: pending |
amount |
number | No | Job value in your currency. Default: 0 |
technician |
string | No | Assigned technician name |
reference |
string | No | Your job reference code. Falls back to top-level external_id |
scheduled_at |
string | No | Date (YYYY-MM-DD) or parseable datetime |
completed_at |
string | No | Date (YYYY-MM-DD) or parseable datetime |
notes |
string | No | Description or internal notes |
customer_external_id |
string | No | Links to a customer synced with the same external_source |
Invoices (entity: "invoices")
| Field | Type | Required | Description |
|---|---|---|---|
number |
string | Yes* | Invoice number. *If omitted, top-level external_id is used as the number. |
total |
number | No | Invoice total. Default: 0 |
tax |
number | No | Tax amount (stored separately; does not auto-calculate total) |
status |
string | No | e.g. unpaid, paid, authorised (authorised maps to unpaid). Default: unpaid |
issued_at |
string | No | Issue date. Default: today |
due_at |
string | No | Due date |
notes |
string | No | Invoice notes |
customer_name |
string | No | Creates/finds customer by name if customer_external_id not set |
customer_external_id |
string | No | Links to customer by external ID (same external_source) |
Linking records
Customer (external_id: "erp-customer-42")
↑
│ customer_external_id
│
Job (external_id: "erp-job-100")
│
│ (optional business link — not enforced by FK from invoice to job)
↓
Invoice (external_id: "erp-invoice-55", customer_external_id: "erp-customer-42")
Use the same external_source on related records (or omit it consistently so all default to the same key name).
Error responses
401 Unauthorized
Missing, invalid, or revoked API key.
{
"message": "Invalid or missing integration API key."
}
403 Forbidden
Tenant suspended/inactive, or key lacks write scope.
{
"message": "This API key does not have write scope."
}
422 Unprocessable Entity
Validation failed (invalid entity, missing payload, batch too large, etc.).
{
"message": "The entity field is required.",
"errors": {
"entity": ["The entity field is required."]
}
}
429 Too Many Requests
Rate limit exceeded. Retry after a short delay.
Integration patterns
Event-driven (recommended)
Call /sync or /sync/batch from your app when data changes:
- Customer created/updated
- Job created, assigned, completed, cancelled
- Invoice issued or marked paid
Initial backfill
Loop existing records and POST to /sync/batch in chunks of 500.
FieldOps (fieldops.kiets.dev)
FieldOps has a built-in Business AI OS settings page per company (paid add-on).
Provisioning flow (multi-tenant):
- Business AI OS — Create a tenant for this customer in
/adminand generate an Integration API key. - FieldOps superadmin — Enable Business AI OS (paid add-on) on that FieldOps company (
admin_feature_flags). - FieldOps company admin — Open Business AI OS in the sidebar, paste:
- Integration API base URL (this server’s
/api/integrations/v1) - API key (
bai_…) - Optional tenant ID (reference only)
- Enable automatic sync
- Integration API base URL (this server’s
- Click Test connection, then Sync all data now, or create or complete a job in FieldOps.
Each FieldOps company on a Professional account can link to a different Business AI OS tenant and API key.
Global FieldOps .env keys are optional dev fallback only:
BUSINESS_AI_INTEGRATION_ENABLED=true
BUSINESS_AI_INTEGRATION_URL=https://your-host.example.com/api/integrations/v1
BUSINESS_AI_INTEGRATION_API_KEY=bai_xxxxxxxx
BUSINESS_AI_INTEGRATION_SOURCE=fieldops
BUSINESS_AI_INTEGRATION_DOCS_URL=https://your-host.example.com/docs/integration-api
External IDs used by FieldOps:
| Entity | external_id pattern |
|---|---|
| Customer | fieldops-customer-{company_id}-{phone_digits} |
| Job | fieldops-job-{id} |
| Invoice | fieldops-invoice-{id} |
Code examples
PHP (Laravel HTTP client)
use Illuminate\Support\Facades\Http;
$baseUrl = config('services.business_ai.base_url');
$key = config('services.business_ai.api_key');
Http::withToken($key)
->acceptJson()
->timeout(10)
->post("{$baseUrl}/sync", [
'entity' => 'jobs',
'external_id' => 'erp-job-'.$job->id,
'external_source' => 'my_erp',
'payload' => [
'title' => $job->title,
'status' => $job->status,
'amount' => $job->total,
'customer_external_id' => 'erp-customer-'.$job->customer_id,
],
])
->throw();
JavaScript (fetch)
await fetch('https://your-host.example.com/api/integrations/v1/sync', {
method: 'POST',
headers: {
Authorization: 'Bearer bai_YOUR_KEY',
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
entity: 'customers',
external_id: 'crm-123',
external_source: 'my_crm',
payload: {
name: 'ACME Corp',
phone: '0312345678',
},
}),
});
Python (requests)
import requests
requests.post(
"https://your-host.example.com/api/integrations/v1/sync",
headers={
"Authorization": "Bearer bai_YOUR_KEY",
"Accept": "application/json",
},
json={
"entity": "invoices",
"external_id": "inv-5001",
"external_source": "accounting",
"payload": {
"number": "INV-5001",
"status": "paid",
"total": 1200.00,
"issued_at": "2026-07-26",
"customer_external_id": "crm-123",
},
},
timeout=10,
)
HTTP examples file
Import docs/integration-api.http into VS Code (REST Client) or JetBrains HTTP Client for ready-to-run requests.
Troubleshooting
table customers has no column named external_source
The tenant database was created before integration sync was added, or tenant migrations were never run. On the Business AI OS server:
php artisan tenants:run migrate \
--option=force=true \
--option=path=database/migrations/tenant/2026_07_26_150000_align_tenant_schema_for_integration.php
Then retry sync from FieldOps (Sync all data now).
New tenants provisioned after integration support get these columns automatically via tenants:migrate.
Changelog
| Version | Notes |
|---|---|
| v1 | GET /health, POST /sync, POST /sync/batch. Entities: customers, jobs, invoices. Bearer API key auth. |
Support
- Create and revoke keys:
/admin→ company → Integration API keys - Platform health:
GET /up(Laravel health check, no auth)