# 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']]);
```

---
