# CRM REST API (v1) Third-party integration surface for the CRM. Every endpoint is tenant-scoped, authenticates via `X-API-Key`, and enforces a per-key hourly rate limit. **Base URL** — `https://crm.omni-desk.com/api/crm/v1` ## Contents - [Authentication](#authentication) - [Rate limiting](#rate-limiting) - [Response envelope + errors](#response-envelope--errors) - [Tenant isolation](#tenant-isolation) - [Bulk delete](#bulk-delete) - [Endpoints](#endpoints) - [Clients](#clients) - [Leads](#leads) - [Pipeline (dynamic)](#pipeline-dynamic) - [Custom fields](#custom-fields) - [Send Email / SMS](#send-email--sms) - [Client sub-resources](#client-sub-resources) - [Contacts](#contacts) - [Invoices](#invoices) - [Payments](#payments) - [Service locations](#service-locations) - [Files](#files) - [Proposals](#proposals) - [Reminders](#reminders) - [Full sample walkthrough](#full-sample-walkthrough) --- ## Authentication Every request must include: ``` X-API-Key: crm_<48-hex chars> ``` Keys are issued and managed by a company admin at **CRM → API Keys** (`/admin/crm/api-keys`). - The plaintext key is shown **once** on issue; only its bcrypt hash is stored server-side. - Keys are bound to one tenant (`company_prefix`). A key from tenant A can never read or write tenant B's rows. - Keys can be scoped (`*` = full, or CSV of resources like `clients,leads,send-email`). - A key can be disabled or deleted at any time from the admin UI — the change takes effect immediately. For quick browser tests you can also pass `?api_key=…`, but production integrations should always use the header. ### Missing or invalid key ```json HTTP/1.1 401 Unauthorized { "success": false, "error": "Invalid or inactive API key" } ``` ### Scope violation ```json HTTP/1.1 403 Forbidden { "success": false, "error": "Key does not have scope: send-email" } ``` --- ## Rate limiting Per-key, trailing 1-hour window. **Platform-wide limit: 300 requests/hour** — this cannot be changed from the UI. The API-keys page shows the field as read-only. **Per-tenant key cap: 5 API keys.** The "New API Key" button is disabled once a tenant hits the cap; the raw POST endpoint returns an error flash with the same message. Delete an existing key (Deleted rows free the slot; toggling a key inactive does not — it still counts). Every response carries: ``` X-RateLimit-Limit: 300 X-RateLimit-Remaining: 214 ``` When the budget is exhausted: ``` HTTP/1.1 429 Too Many Requests Retry-After: 3600 X-RateLimit-Limit: 300 X-RateLimit-Remaining: 0 { "success": false, "error": "Rate limit exceeded", "limit": 300, "used": 301, "window": "1 hour" } ``` Every request — including 4xx responses — counts against the budget, so spam of bad requests will still lock the key out. --- ## Response envelope + errors Success: ```json { "success": true, "data": { ... } } ``` Lists add paging metadata: ```json { "success": true, "count": 50, "total": 217, "limit": 50, "offset": 0, "data": [ ... ] } ``` Errors: ```json { "success": false, "error": "human-readable message" } ``` Standard status codes: **200** OK, **201** Created, **202** Accepted (queued), **400** malformed body, **401** unauthenticated, **403** wrong scope, **404** not found, **422** validation, **429** rate-limited, **500** server error. --- ## Tenant isolation Every SELECT filters by the caller's `company_prefix`. Two cross-tenant safeguards: - The filter injects the tenant onto the request **before** the controller runs; the controller never reads it from a query param. - Every write stamps the tenant column (`company_prefix` on clients / tickets, `business_unit` on leads / pipeline). Reads for foreign IDs simply return `404` — the same shape as "row doesn't exist". **Bulk endpoints inherit the same guarantee.** `POST /{resource}/bulk-delete` runs one tenant-scoped SQL — foreign ids are silently ignored (never deleted), and the response's `ids_not_found` field lists which ids the caller sent that didn't match anything under their tenant (unknown OR foreign — the API doesn't distinguish). --- ## Bulk delete Every domain that has `DELETE /{resource}/{id}` also exposes `POST /{resource}/bulk-delete` with the same tenant guarantee. **Request** ```json { "ids": [1, 2, 3, 4, 5] } ``` - `ids` is required; must be an array of positive integers. - Duplicates are collapsed. Non-numeric values are dropped. - **Hard cap: 500 ids per call.** Split larger batches yourself. **Response** ```json { "success": true, "data": { "requested": 5, "deleted": 3, "ids_not_found": [4, 5] } } ``` - `deleted` — how many rows actually left the DB (only rows this tenant owned). - `ids_not_found` — ids the caller sent that either don't exist OR belong to another tenant. The API doesn't tell you which — both are "not_found". **Pipeline campaigns** — same body, extra `drop_table` flag: ```json { "ids": [58, 59, 60], "drop_table": true } ``` Also accepts `?drop_table=1` as a query param. When true, each per-campaign records table is dropped after its metadata row is removed. A per-table drop failure does NOT roll back the batch; the response lists each result under `tables[]` with any warnings. **Rate limiter** — one call = one rate-limit hit, regardless of how many ids are in the batch. Prefer one bulk call over N single-DELETE loops. **cURL** ```bash curl -X POST "$BASE/clients/bulk-delete" \ -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{"ids":[101,102,103]}' ``` --- ## Endpoints ### Clients Writable fields: `name`, `email`, `phone`, `mobile`, `website`, `primary_contact`, `address`, `city`, `country`, `short_note`, `CustomerNumber`, `currency`, `vat`, `language`, `active`, plus 30+ others — pass any writable column in the body and it will be stored. | Method | Path | Purpose | |---|---|---| | GET | `/clients?q=&status=&limit=&offset=` | List (paginated) with optional `q` (name/email/phone/CustomerNumber) and `status` (`Active`/`Inactive`) | | GET | `/clients/search?q=…` | Search by name/email/phone/mobile/CustomerNumber | | GET | `/clients/statuses` | List tenant statuses | | POST | `/clients` | Create | | GET | `/clients/{id}` | Get one | | PUT | `/clients/{id}` | Update (full or partial) | | PATCH| `/clients/{id}` | Alias of PUT | | DELETE | `/clients/{id}` | Delete | | POST | `/clients/bulk-delete` | Bulk delete — body: `{"ids":[1,2,3]}` (max 500) | | PATCH| `/clients/{id}/status` | Set `active` — body: `{"status":"Active"}` | **Create a client — cURL** ```bash curl -X POST "$BASE/clients" \ -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{ "name": "Acme Ltd", "email": "hello@acme.example", "phone": "+2348012345678", "primary_contact": "Ada Lovelace", "country": "NG", "city": "Lagos", "custom_fields": { "industry": "Technology", "annual_revenue": 5000000, "account_tier": "Enterprise" } }' ``` **Create a client — PHP (Guzzle)** ```php use GuzzleHttp\Client; $http = new Client([ 'base_uri' => 'https://crm.omni-desk.com/api/crm/v1/', 'headers' => ['X-API-Key' => 'crm_e8f2a4…', 'Content-Type' => 'application/json'], ]); $res = $http->post('clients', ['json' => [ 'name' => 'Acme Ltd', 'email' => 'hello@acme.example', 'phone' => '+2348012345678', 'custom_fields' => [ 'industry' => 'Technology', 'annual_revenue' => 5000000, 'account_tier' => 'Enterprise', ], ]]); $body = json_decode($res->getBody(), true); $cid = $body['id']; // e.g. 4213 ``` **Create a client — JavaScript (fetch)** ```js const res = await fetch(`${BASE}/clients`, { method: "POST", headers: { "X-API-Key": KEY, "Content-Type": "application/json" }, body: JSON.stringify({ name: "Acme Ltd", email: "hello@acme.example", phone: "+2348012345678", custom_fields: { industry: "Technology", annual_revenue: 5000000, account_tier: "Enterprise", }, }), }); const { id, custom_fields } = await res.json(); ``` **Response** ```json { "success": true, "id": 4213, "data": { "client_id": 4213, "name": "Acme Ltd", "email": "hello@acme.example", "custom_field_1": "Technology", "custom_field_2": "5000000", "custom_field_3": "Enterprise", "custom_field_4": null, "custom_field_5": null, ... }, "custom_fields": { "written": 3, "skipped": [] } } ``` **List clients — response now embeds a `custom_fields` object per row (name-keyed):** ```json { "success": true, "count": 2, "total": 2, "limit": 50, "offset": 0, "data": [ { "client_id": 4213, "name": "Acme Ltd", "custom_field_1": "Technology", "custom_fields": { "industry": "Technology", "annual_revenue": "5000000", "account_tier": "Enterprise" } }, { "client_id": 4214, "name": "Globex Inc", "custom_field_1": "Retail", "custom_fields": { "industry": "Retail", "annual_revenue": "2000000", "account_tier": "SMB" } } ] } ``` **Get a single client — the same shape, one row:** ```bash curl -H "X-API-Key: $KEY" "$BASE/clients/4213" ``` ```json { "success": true, "data": { "client_id": 4213, "name": "Acme Ltd", "custom_field_1": "Technology", ... }, "custom_fields": { "industry": "Technology", "annual_revenue": "5000000", "account_tier": "Enterprise" } } ``` --- ### Leads Every field is writable via the body; `business_unit` is auto-injected. | Method | Path | Purpose | |---|---|---| | GET | `/leads?q=&status=&limit=&offset=` | List; `status` filters by `lead_status_id` | | GET | `/leads/search?q=…` | Search by lead_name/contact_name/email/phone/company_name | | GET | `/leads/statuses` | List statuses | | POST | `/leads` | Create | | GET | `/leads/{id}` | Get one | | PUT/PATCH | `/leads/{id}` | Update | | DELETE | `/leads/{id}` | Delete | | POST | `/leads/bulk-delete` | Bulk delete — body: `{"ids":[1,2,3]}` (max 500) | | PATCH | `/leads/{id}/status` | Body: `{"status_id": 3}` | **Create a lead — cURL** ```bash curl -X POST "$BASE/leads" \ -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{ "lead_name": "Acme Ltd — inbound demo", "contact_name": "Grace Hopper", "email": "grace@acme.example", "phone": "+2348022222222", "company_name": "Acme Ltd", "custom_fields": { "lead_source": "Google Ads", "estimated_value": 250000, "priority": "High" } }' ``` **Create a lead — PHP** ```php $body = json_decode($http->post('leads', ['json' => [ 'lead_name' => 'Acme Ltd — inbound demo', 'contact_name' => 'Grace Hopper', 'email' => 'grace@acme.example', 'phone' => '+2348022222222', 'company_name' => 'Acme Ltd', 'custom_fields' => [ 'lead_source' => 'Google Ads', 'estimated_value' => 250000, 'priority' => 'High', ], ]])->getBody(), true); $leadId = $body['id']; ``` **Create a lead — JavaScript** ```js const res = await fetch(`${BASE}/leads`, { method: "POST", headers: { "X-API-Key": KEY, "Content-Type": "application/json" }, body: JSON.stringify({ lead_name: "Acme Ltd — inbound demo", contact_name: "Grace Hopper", email: "grace@acme.example", phone: "+2348022222222", company_name: "Acme Ltd", custom_fields: { lead_source: "Google Ads", estimated_value: 250000, priority: "High", }, }), }); const { id } = await res.json(); ``` **PATCH status — cURL / PHP / JS** ```bash curl -X PATCH "$BASE/leads/8172/status" \ -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{"status_id": 3}' ``` ```php $http->request('PATCH', "leads/$leadId/status", ['json' => ['status_id' => 3]]); ``` ```js await fetch(`${BASE}/leads/8172/status`, { method: "PATCH", headers: { "X-API-Key": KEY, "Content-Type": "application/json" }, body: JSON.stringify({ status_id: 3 }) }); ``` --- ### Pipeline (dynamic) Each pipeline campaign has its **own table**. The API resolves the table dynamically after verifying the campaign belongs to the caller's tenant. | Method | Path | Purpose | |---|---|---| | GET | `/pipeline/campaigns` | List tenant campaigns | | POST | `/pipeline/campaigns` | Create a campaign | | GET | `/pipeline/campaigns/{id}` | Get one | | PUT/PATCH | `/pipeline/campaigns/{id}` | Update | | DELETE | `/pipeline/campaigns/{id}` | Delete a campaign (query `?drop_table=1` also drops its records table) | | POST | `/pipeline/campaigns/bulk-delete` | Bulk delete — body: `{"ids":[1,2,3], "drop_table":false}` (max 500) | | GET | `/pipeline/statuses` | List statuses | | GET | `/pipeline/{campaign_id}?q=&status=&limit=&offset=` | List rows in a pipeline (search works across `name,contact_name,email,phone,company_name`) | | POST | `/pipeline/{campaign_id}` | Create a pipeline row | | GET | `/pipeline/{campaign_id}/{row_id}` | Get one | | PUT/PATCH | `/pipeline/{campaign_id}/{row_id}` | Update | | DELETE | `/pipeline/{campaign_id}/{row_id}` | Delete | | PATCH | `/pipeline/{campaign_id}/{row_id}/status` | Body: `{"status_id": N}` — writes `pipeline_status_id` if present, else `status` | **List campaigns → pick one → read rows — cURL** ```bash # 1) get available campaigns curl -H "X-API-Key: $KEY" "$BASE/pipeline/campaigns" # 2) list rows in campaign 58 (pipeline_id is the PK; response includes custom_fields per row) curl -H "X-API-Key: $KEY" "$BASE/pipeline/58?limit=25" ``` **Create a pipeline row — cURL** ```bash curl -X POST "$BASE/pipeline/58" \ -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{ "pipeline_name": "Acme Ltd", "contact_name": "Ada Lovelace", "phone": "+2348012345678", "email": "hello@acme.example", "custom_fields": { "campaign_offer": "Q4 30% renewal discount", "priority": "Hot" } }' ``` **Create a pipeline row — PHP** ```php $http = new \GuzzleHttp\Client(['base_uri' => $BASE, 'headers' => ['X-API-Key' => $KEY]]); $campaigns = json_decode($http->get('pipeline/campaigns')->getBody(), true)['data']; $camp = $campaigns[0]['campaign_id']; $row = json_decode($http->post("pipeline/$camp", ['json' => [ 'pipeline_name' => 'Acme Ltd', 'contact_name' => 'Ada Lovelace', 'phone' => '+2348012345678', 'custom_fields' => [ 'campaign_offer' => 'Q4 30% renewal discount', 'priority' => 'Hot', ], ]])->getBody(), true); echo "Created pipeline row " . $row['id'] . PHP_EOL; ``` **Create a pipeline row — JavaScript** ```js const camps = await fetch(`${BASE}/pipeline/campaigns`, { headers: { "X-API-Key": KEY }}).then(r => r.json()); const camp = camps.data[0].campaign_id; const res = await fetch(`${BASE}/pipeline/${camp}`, { method: "POST", headers: { "X-API-Key": KEY, "Content-Type": "application/json" }, body: JSON.stringify({ pipeline_name: "Acme Ltd", contact_name: "Ada Lovelace", phone: "+2348012345678", custom_fields: { campaign_offer: "Q4 30% renewal discount", priority: "Hot", }, }), }); const { id } = await res.json(); ``` --- ### Custom fields Custom fields let each tenant attach up to **10 extra columns per module** (Clients, Leads, Pipeline campaigns) — for things like `industry`, `annual_revenue`, `priority_reason`. **How it works** - An admin defines a field at **CRM → Settings → Custom Fields → New**. Each definition is auto-assigned a physical column slot 1-10 within every module it targets (`custom_field_1` … `custom_field_10`). The list page shows the exact column each field maps to. - Every write via the API stores the value directly on the parent row's `custom_field_<slot>` column — one atomic `UPDATE`. No indirection, no join. - Reads return the values in two shapes on every list/get response: - `data.custom_field_1 … custom_field_10` — the raw column values - `custom_fields: { field_name: value }` — the same values keyed by the definition's snake_case name, ready for `record.custom_fields.industry` - **The per-module limit is 10.** Creating an 11th field for a module returns `422` with a clear message; the admin must remove or reassign one first. **Discover a tenant's active definitions** ``` GET /custom-fields/{module} # module = client | lead | pipeline ``` ```bash curl -H "X-API-Key: $KEY" "$BASE/custom-fields/client" ``` ```json { "success": true, "module": "client", "definitions": [ { "id": 41, "name": "industry", "label": "Industry", "field_type": "dropdown", "required": false, "slot": 1, "column": "custom_field_1", "options": [ {"id": 42, "value": "Technology"}, {"id": 43, "value": "Retail"} ] }, { "id": 44, "name": "annual_revenue", "label": "Annual Revenue", "field_type": "numeric", "required": false, "slot": 2, "column": "custom_field_2", "options": [] }, { "id": 45, "name": "account_tier", "label": "Account Tier", "field_type": "text", "required": true, "slot": 3, "column": "custom_field_3", "options": [] } ] } ``` **Write custom values as part of any create / update** (shown above under Clients / Leads / Pipeline) — just add a `custom_fields` object to the body. Unknown field names are skipped and reported in the response: ```json { "success": true, "id": 4213, "custom_fields": { "written": 2, "skipped": ["not_a_real_field"] } } ``` **Write only custom values (no other row changes)** ``` POST /custom-fields/{module}/{record_id} ``` ```bash curl -X POST "$BASE/custom-fields/client/4213" \ -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{ "industry": "Retail", "annual_revenue": 850000 }' ``` **Read custom values on their own** ```bash curl -H "X-API-Key: $KEY" "$BASE/custom-fields/client/4213" ``` ```json { "success": true, "fields": [ { "id": 41, "name": "industry", "label": "Industry", "field_type": "dropdown", "slot": 1, "column": "custom_field_1" }, { "id": 44, "name": "annual_revenue", "label": "Annual Revenue", "field_type": "numeric", "slot": 2, "column": "custom_field_2" } ], "values": { "industry": "Retail", "annual_revenue": "850000" } } ``` **Notes on values** - Multi-select values are stored as JSON strings in the slot column — decode client-side if needed. - Setting a value to `null` clears the slot. - Physical column values persist even after the definition is deleted; if a new definition later lands on the same slot, the old value becomes visible again. Clear values explicitly before archiving a field if that matters to you. --- ### Send Email / SMS Both endpoints **queue** the message (status `pending`); it is delivered in the background and the status becomes `sent` or `failed`. | Method | Path | Purpose | |---|---|---| | POST | `/send/email/{entity}/{id}` | Queue email for `client`/`lead`/`pipeline` | | POST | `/send/sms/{entity}/{id}` | Queue SMS for `client`/`lead`/`pipeline` | Body (both): ```json { "recipient": "optional — if omitted, pulled from the target row's email / mobile", "subject": "for email only", "body": "message body (required)" } ``` Response: `202 Accepted` ```json { "success": true, "queued": 918, "channel": "email", "recipient": "ada@acme.example" } ``` --- ### Client sub-resources Every sub-resource sits under `/clients/{client_id}/…`. The client_id is verified against the caller's tenant before any sub-resource read/write. #### Contacts | Method | Path | Purpose | |---|---|---| | GET | `/clients/{cid}/contacts` | List | | POST | `/clients/{cid}/contacts` | Create — required: `contact_type`, `contact_name`, `contact_phone`, `contact_email` | | PATCH | `/clients/{cid}/contacts/{id}` | Update (any of the four fields) | #### Invoices | Method | Path | Purpose | |---|---|---| | GET | `/clients/{cid}/invoices` | List (non-deleted, tenant-scoped) | | POST | `/clients/{cid}/invoices` | Create — any invoice field accepted via the body | #### Payments Payments are linked via `invoices_id` to an invoice owned by the client. | Method | Path | Purpose | |---|---|---| | GET | `/clients/{cid}/payments` | List — only payments on this client's invoices | | POST | `/clients/{cid}/payments` | Body: `{ invoice_id, amount, currency?, payment_method?, payment_date?, reference?, notes? }` | #### Service locations | Method | Path | Purpose | |---|---|---| | GET | `/clients/{cid}/locations` | List | | POST | `/clients/{cid}/locations` | Required: `country, state, lga, address, contact_person, contact_phone` | #### Files | Method | Path | Purpose | |---|---|---| | GET | `/clients/{cid}/files` | List | | POST | `/clients/{cid}/files` | Multipart upload (`file` field) OR JSON body with `file_url` + `file_name` | ```bash # multipart curl -X POST "$BASE/clients/4213/files" \ -H "X-API-Key: $KEY" \ -F "file=@/tmp/agreement.pdf" -F "title=Signed agreement" ``` #### Proposals Proposals attached to the client. | Method | Path | Purpose | |---|---|---| | GET | `/clients/{cid}/proposals` | List | | POST | `/clients/{cid}/proposals` | Required: `subject`. Optional: `reference_no, proposal_date, currency, notes, status` | #### Reminders Reminders attached to the client. | Method | Path | Purpose | |---|---|---| | GET | `/clients/{cid}/reminders` | List (ordered by date ASC) | | POST | `/clients/{cid}/reminders` | Required: `date` (Y-m-d H:i:s). Optional: `description, notify_by_email` | --- ## Full sample walkthrough End-to-end: create a client, add a contact + reminder, queue an email, then flip its status. ```bash BASE="https://crm.omni-desk.com/api/crm/v1" KEY="crm_e8f2a4…" # 1) create a client CID=$(curl -s -X POST "$BASE/clients" \ -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{"name":"Acme Ltd","email":"hello@acme.example","phone":"+2348012345678"}' \ | jq -r '.id') echo "Client id: $CID" # 2) add a contact person curl -s -X POST "$BASE/clients/$CID/contacts" \ -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{"contact_type":"Primary","contact_name":"Ada Lovelace","contact_phone":"+2348011111111","contact_email":"ada@acme.example"}' # 3) schedule a reminder for tomorrow 09:00 DATE=$(date -v+1d +"%Y-%m-%d 09:00:00") curl -s -X POST "$BASE/clients/$CID/reminders" \ -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d "{\"date\":\"$DATE\",\"description\":\"Follow up on demo\",\"notify_by_email\":\"Yes\"}" # 4) queue a welcome email — recipient auto-resolves from the client row curl -s -X POST "$BASE/send/email/client/$CID" \ -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{"subject":"Welcome to CompanyX","body":"Hi Ada — great to have you onboard."}' # 5) mark the client Inactive curl -s -X PATCH "$BASE/clients/$CID/status" \ -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{"status":"Inactive"}' ``` ### JS (fetch) — same flow ```js const BASE = "https://crm.omni-desk.com/api/crm/v1"; const KEY = "crm_e8f2a4…"; const h = { "X-API-Key": KEY, "Content-Type": "application/json" }; const post = (p, b) => fetch(`${BASE}${p}`, { method: "POST", headers: h, body: JSON.stringify(b) }).then(r => r.json()); const patch= (p, b) => fetch(`${BASE}${p}`, { method: "PATCH", headers: h, body: JSON.stringify(b) }).then(r => r.json()); const c = await post("/clients", { name: "Acme Ltd", email: "hello@acme.example", phone: "+2348012345678" }); const cid = c.id; await post(`/clients/${cid}/contacts`, { contact_type: "Primary", contact_name: "Ada Lovelace", contact_phone: "+2348011111111", contact_email: "ada@acme.example", }); const iso = new Date(Date.now() + 24*3600*1000).toISOString().slice(0,10) + " 09:00:00"; await post(`/clients/${cid}/reminders`, { date: iso, description: "Follow up on demo", notify_by_email: "Yes" }); await post(`/send/email/client/${cid}`, { subject: "Welcome", body: "Hi Ada — great to have you onboard." }); await patch(`/clients/${cid}/status`, { status: "Inactive" }); ``` ### PHP (Guzzle) — same flow ```php use GuzzleHttp\Client; $http = new Client([ 'base_uri' => 'https://crm.omni-desk.com/api/crm/v1/', 'headers' => ['X-API-Key' => 'crm_e8f2a4…', 'Content-Type' => 'application/json'], ]); $cid = json_decode($http->post('clients', ['json' => [ 'name' => 'Acme Ltd', 'email' => 'hello@acme.example', 'phone' => '+2348012345678', ]])->getBody(), true)['id']; $http->post("clients/$cid/contacts", ['json' => [ 'contact_type' => 'Primary', 'contact_name' => 'Ada Lovelace', 'contact_phone' => '+2348011111111', 'contact_email' => 'ada@acme.example', ]]); $http->post("clients/$cid/reminders", ['json' => [ 'date' => (new DateTimeImmutable('tomorrow 09:00'))->format('Y-m-d H:i:s'), 'description' => 'Follow up on demo', 'notify_by_email' => 'Yes', ]]); $http->post("send/email/client/$cid", ['json' => [ 'subject' => 'Welcome', 'body' => 'Hi Ada — great to have you onboard.', ]]); $http->patch("clients/$cid/status", ['json' => ['status' => 'Inactive']]); ``` ---