# Zapini API — Kanban

**Version:** 1.0.0
**Base URL:** `https://zapini.app/api/v1`

---

## Authentication

All endpoints require a Bearer Token:

```
Authorization: Bearer sk_your_api_token
Accept: application/json
Content-Type: application/json
```

Generate API tokens in your admin panel under **API Docs → Manage Tokens**.

---

## Kanban API

**Base path:** `/api/v1/kanban`
**Requirement:** `kanban_enabled` must be true on your tenant account.

> **Canonical prefix: `/api/v1/crm`.** The former `/api/v1/kanban` still serves
> exactly the same routes, with the same payloads — no published integration has
> to change. The examples below use both.


The Kanban API provides full CRUD access to boards, columns, and leads. Use it to build CRM integrations, automate pipeline management, or sync with external tools.

---

## Boards

### List Boards
`GET /api/v1/kanban/boards`

Returns all Kanban boards for the authenticated instance.

```bash
curl -s -H "Authorization: Bearer sk_your_token" \
  "https://zapini.app/api/v1/kanban/boards"
```

**Response:**
```json
{
  "success": true,
  "data": [
    {
      "uuid": "board-uuid",
      "name": "Sales Pipeline",
      "description": null,
      "is_default": true,
      "whatsapp_instance_uuid": "instance-uuid",
      "auto_detect_leads_enabled": false,
      "auto_detect_score_threshold": 50,
      "columns_count": 6,
      "created_at": "2026-01-01T00:00:00+00:00"
    }
  ]
}
```

---

### Create Board
`POST /api/v1/kanban/boards`

Creates a new board with default columns (New Lead, Qualified, Proposal, Negotiation, Won, Lost).

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| name | string | Yes | Board name |
| description | string | No | Board description |
| is_default | boolean | No | Set as default board |

```bash
curl -s -X POST \
  -H "Authorization: Bearer sk_your_token" \
  -H "Content-Type: application/json" \
  -d '{"name":"Q2 Pipeline","is_default":false}' \
  "https://zapini.app/api/v1/kanban/boards"
```

---

### Get Board
`GET /api/v1/kanban/boards/{uuid}`

Returns board details with statistics and columns.

```bash
curl -s -H "Authorization: Bearer sk_your_token" \
  "https://zapini.app/api/v1/kanban/boards/board-uuid"
```

**Response includes `stats`:**
```json
{
  "stats": {
    "total_leads": 12,
    "open_leads": 9,
    "won_leads": 2,
    "lost_leads": 1,
    "total_value": 15000.00,
    "won_value": 5000.00,
    "conversion_rate": 66.7,
    "avg_time_to_close": 14.5
  }
}
```

---

### Update Board
`PUT /api/v1/kanban/boards/{uuid}`

| Field | Type | Description |
|-------|------|-------------|
| name | string | Board name |
| description | string | Board description |
| is_default | boolean | Set as default (unsets others) |
| auto_detect_leads_enabled | boolean | Enable auto lead detection |
| auto_detect_score_threshold | integer | Min score (0–100) for auto detection |

---

### Delete Board
`DELETE /api/v1/kanban/boards/{uuid}`

Deletes the board and all its columns and leads.

---

## Columns

### List Columns
`GET /api/v1/kanban/boards/{board_uuid}/columns`

Returns columns ordered by position.

---

### Create Column
`POST /api/v1/kanban/boards/{board_uuid}/columns`

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| name | string | Yes | Column name |
| color | string | No | Hex color (e.g. `#3B82F6`) |
| position | integer | No | Column position (appended if omitted) |
| wip_limit | integer | No | Max leads in this column |
| is_final | boolean | No | Whether this is a final stage column |

---

### Reorder Columns
`POST /api/v1/kanban/boards/{board_uuid}/columns/reorder`

```json
{
  "order": ["col-uuid-1", "col-uuid-2", "col-uuid-3"]
}
```

---

### Update Column
`PUT /api/v1/kanban/columns/{uuid}`

| Field | Type | Description |
|-------|------|-------------|
| name | string | Column name |
| color | string | Hex color |
| wip_limit | integer | WIP limit |
| is_final | boolean | Final stage flag |
| min_ai_score | integer | Min AI score for auto-routing |
| max_ai_score | integer | Max AI score for auto-routing |

---

### Delete Column
`DELETE /api/v1/kanban/columns/{uuid}`

| Query | Type | Description |
|-------|------|-------------|
| move_leads_to_uuid | string | UUID of column to move leads to (optional) |

---

## Leads

### List Leads
`GET /api/v1/kanban/boards/{board_uuid}/leads`

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| status | string | open | `open` \| `won` \| `lost` \| `all` |
| priority | string | — | `low` \| `medium` \| `high` \| `urgent` |
| column_uuid | string | — | Filter by column |
| search | string | — | Search by title, phone, email, notes |

```bash
curl -s -H "Authorization: Bearer sk_your_token" \
  "https://zapini.app/api/v1/kanban/boards/board-uuid/leads?status=open&priority=high"
```

---

### Create Lead
`POST /api/v1/kanban/leads`

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| board_uuid | string | Yes | Target board UUID |
| column_uuid | string | No | Target column (first column if omitted) |
| title | string | Yes | Lead title |
| phone_number | string | Yes | Contact phone number |
| email | string | No | Contact email |
| value | decimal | No | Deal value |
| priority | string | No | `low` \| `medium` \| `high` \| `urgent` |
| notes | string | No | Initial notes |
| due_date | date | No | Due date (YYYY-MM-DD) |
| custom_fields | object | No | Custom key-value fields |

```bash
curl -s -X POST \
  -H "Authorization: Bearer sk_your_token" \
  -H "Content-Type: application/json" \
  -d '{
    "board_uuid": "board-uuid",
    "title": "John Doe - Pro Plan",
    "phone_number": "5511999999999",
    "value": 299.00,
    "priority": "high"
  }' \
  "https://zapini.app/api/v1/kanban/leads"
```

**Response:**
```json
{
  "success": true,
  "data": {
    "uuid": "lead-uuid",
    "title": "John Doe - Pro Plan",
    "phone_number": "5511999999999",
    "value": "299.00",
    "value_formatted": "R$ 299,00",
    "priority": "high",
    "priority_label": "High",
    "source": "api",
    "ai_score": null,
    "is_open": true,
    "is_won": false,
    "is_lost": false,
    "is_overdue": false,
    "column": {
      "uuid": "col-uuid",
      "name": "New Lead",
      "color": "#3B82F6"
    },
    "created_at": "2026-01-01T00:00:00+00:00"
  }
}
```

---

### Get Lead
`GET /api/v1/kanban/leads/{uuid}`

Returns full lead details including notes, custom_fields, ai_analysis, and lost_reason.

---

### Update Lead
`PUT /api/v1/kanban/leads/{uuid}`

| Field | Type | Description |
|-------|------|-------------|
| title | string | Lead title |
| phone_number | string | Phone number |
| email | string | Email |
| value | decimal | Deal value |
| priority | string | Priority level |
| notes | string | Notes |
| due_date | date | Due date |
| custom_fields | object | Custom fields |

---

### Delete Lead
`DELETE /api/v1/kanban/leads/{uuid}`

Soft-deletes the lead (can be recovered from database).

---

### Move Lead
`POST /api/v1/kanban/leads/{uuid}/move`

```json
{
  "column_uuid": "target-column-uuid",
  "position": 0
}
```

If `position` is omitted, the lead is appended at the end. Moving to a "Won" or "Lost" final column automatically sets the lead status.

---

### Mark as Won
`POST /api/v1/kanban/leads/{uuid}/won`

```json
{
  "value": 299.00
}
```

---

### Mark as Lost
`POST /api/v1/kanban/leads/{uuid}/lost`

```json
{
  "reason": "Budget constraints"
}
```

---

### Add Note
`POST /api/v1/kanban/leads/{uuid}/note`

```json
{
  "note": "Interested in annual subscription. Follow up next week."
}
```

Notes are appended with timestamp and user name.

---

### Get Activities
`GET /api/v1/kanban/leads/{uuid}/activities`

Returns the full activity timeline for a lead.

**Response:**
```json
{
  "success": true,
  "data": [
    {
      "id": 1,
      "type": "created",
      "description": null,
      "metadata": { "column_name": "New Lead" },
      "user": { "id": 1, "name": "John" },
      "created_at": "2026-01-01T00:00:00+00:00",
      "created_at_human": "2 days ago"
    }
  ]
}
```

---

### AI Qualify Lead
`POST /api/v1/kanban/leads/{uuid}/ai-qualify`

Runs AI analysis on the lead (requires AI integration configured).

**Response:**
```json
{
  "success": true,
  "data": {
    "lead": { "uuid": "...", "ai_score": 82 },
    "ai_result": {
      "score": 82,
      "analysis": { "summary": "High-intent lead...", "strengths": [] }
    }
  }
}
```

---

## Searching leads across the whole CRM

`GET /api/v1/crm/leads`

Searches leads across the entire tenant, without knowing which board they sit on
— the way to answer "does this phone number already have a card?".
**Always paginated.**

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| search | string | — | Title, phone, email or notes |
| phone_number | string | — | Matches digits only: `+55 11 9…`, `55119…` and `(11) 9…` all find the same lead |
| board_uuid | string | — | Restrict to one board |
| column_uuid | string | — | Restrict to one column |
| status | string | all | `open` \| `won` \| `lost` \| `all` |
| priority | string | — | `low` \| `medium` \| `high` \| `urgent` |
| source | string | — | `api`, `conversation`, `widget`, `auto_detect`… |
| assigned_to | int | — | Assignee id (primary or not) |
| created_from / created_to | date | — | Creation window |
| sort | string | created_at | `created_at`, `updated_at`, `value`, `ai_score`, `due_date`, `title`, `last_activity_at`, `position` |
| direction | string | desc | `asc` \| `desc` |
| per_page | int | 50 | 1–200 |

```bash
curl -s -H "Authorization: Bearer sk_your_token" \
  "https://zapini.app/api/v1/crm/leads?phone_number=11999990001&status=all"
```

The response carries `meta.pagination`, and each lead includes its board:

```json
{
  "success": true,
  "data": [
    { "uuid": "…", "title": "John", "board": { "uuid": "…", "name": "Sales" } }
  ],
  "meta": { "pagination": { "total": 137, "per_page": 50, "current_page": 1, "last_page": 3 } }
}
```

---

## Paginating the board list

`GET /api/v1/crm/boards/{board_uuid}/leads`

Without `per_page` the response is still the full list (the app builds every
column at once). With `per_page` it paginates — which is what stops a client from
pulling thousands of cards to read the first few. It also accepts `assigned_to`,
`created_from`, `created_to`, `sort` and `direction`.

---

## Bulk import

`POST /api/v1/crm/boards/{board_uuid}/leads/import`

Up to **500 leads** per call. Every row takes the same path as
`POST /crm/leads` — custom fields validated, sequences numbered, formulas
resolved — so an imported record is identical to a typed one.

Never all-or-nothing: good rows go in, bad ones come back with their index and
the error.

| Field | Type | Description |
|-------|------|-------------|
| leads[] | array | Required, 1–500 rows |
| leads[].title | string | Required |
| leads[].phone_number | string | Required outside the modular CRM |
| leads[].column_uuid / column_name | string | Target column (name matches case-insensitively) |
| leads[].custom_fields | object | The board's custom fields |
| leads[].assignee_ids | int[] | Assignees |
| skip_duplicates | bool | Skips a phone number that already has a card on the board |

```json
{
  "success": true,
  "data": {
    "created_count": 2, "skipped_count": 1, "error_count": 1,
    "created": [{ "index": 0, "uuid": "…", "title": "Imported 1" }],
    "errors":  [{ "index": 3, "error": "phone_number is required" }]
  }
}
```

---

## Lead operations

| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/crm/leads/{uuid}/assign` | Set assignees. `assignee_ids: []` clears them; `primary_id` must be in the list |
| POST | `/crm/leads/{uuid}/ignore` | Discards the lead **and** blocks the number in auto-detection. Deleting without ignoring lets the card come back on its own |
| POST | `/crm/leads/{uuid}/convert-to-ticket` | Opens a ticket from the lead. Idempotent: an already-converted lead returns the existing ticket with `already_existed: true` |
| POST | `/crm/leads/{uuid}/ai-suggest` | AI-suggested next action |
| POST | `/crm/leads/from-messages` | Creates a lead from specific messages (`message_ids`, up to 100) |

---

## Lead attachments

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/crm/leads/{uuid}/attachments` | List |
| POST | `/crm/leads/{uuid}/attachments` | Upload (multipart, `file` field) |
| GET | `/crm/leads/{uuid}/attachments/{attachment_uuid}` | Download |
| DELETE | `/crm/leads/{uuid}/attachments/{attachment_uuid}` | Delete |

Limits: 25 MB per file, 10 attachments per lead. Private disk — downloads are
served by the endpoint, never from a public URL.

---

## Board stats and duplication

`GET /api/v1/crm/boards/{board_uuid}/stats`

```json
{
  "success": true,
  "data": {
    "board_uuid": "…",
    "stats": { "total_leads": 120, "open_leads": 80, "won_leads": 30, "lost_leads": 10,
               "total_value": 45000, "won_value": 22000, "conversion_rate": 25 },
    "by_column": [ { "uuid": "…", "name": "New", "leads_count": 12, "total_value": 3400 } ]
  }
}
```

`POST /api/v1/crm/boards/{board_uuid}/duplicate` — copies the board with its
columns (leads are not copied). Required field: `name`.

`POST /api/v1/crm/columns/{column_uuid}/clear` — deletes every lead in the column
and returns `deleted`. **Destructive, with no confirmation step.**

---

## Automations

| Method | Endpoint |
|--------|----------|
| GET | `/crm/boards/{board_uuid}/automations` |
| POST | `/crm/boards/{board_uuid}/automations` |
| PUT | `/crm/automations/{uuid}` |
| DELETE | `/crm/automations/{uuid}` |
| POST | `/crm/automations/{uuid}/test` |

**Triggers:** `lead_created`, `lead_moved`, `lead_idle`, `message_received`,
`scheduled`, `ai_score_changed`.

**Actions:** `move_column`, `assign_user`, `send_message`, `add_tag`,
`ai_qualify`, `ai_summarize`, `notify`, `webhook`.

The configuration is checked on write: `move_column` requires an
`action_config.column_id` from **this** board and `assign_user` an
`action_config.user_id` from **this** account — otherwise the automation would
only fail months later, on its first run.

`POST /crm/automations/{uuid}/test` takes a `lead_uuid` and runs the automation
against a real lead, so you can check the configuration before letting it fire on
its own.

---

## Lead auto-detection

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/crm/boards/{uuid}/auto-detection` | Current rules + available tags |
| PUT | `/crm/boards/{uuid}/auto-detection` | Save the rules |
| POST | `/crm/boards/{uuid}/auto-detection/preview` | What **would** be imported, creating nothing |
| POST | `/crm/boards/{uuid}/auto-detection/sync` | Create leads for the picked items (`items[]`, up to 100) |
| GET | `/crm/boards/{uuid}/ignored-contacts` | Blocked contacts |
| POST | `/crm/boards/{uuid}/ignored-contacts` | Return a contact to detection (`contact_number`) |

---

## Custom fields (modular CRM)

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/crm/field-definitions` | Schema for building the form (pass `board_uuid`) |
| GET | `/crm/boards/{uuid}/fields` | Management: includes disabled fields and how many records already hold a value in each |
| POST | `/crm/boards/{uuid}/fields` | Create a field |
| POST | `/crm/boards/{uuid}/fields/reorder` | Reorder (`ids[]` in the desired order) |
| PUT | `/crm/fields/{id}` | Update |
| DELETE | `/crm/fields/{id}` | Remove from the form |

**Inheritance:** a board with no fields of its own uses the tenant's global set.
The first own field COPIES the global set before saving — the response reports
`copied_from_global` with how many came along. Without that, the form would
shrink to the single field just created.

**`name` is not editable:** it is the key inside each lead's `custom_fields`.
Renaming it would orphan every stored value. Deleting a field removes it from the
form but **keeps** the values — the response returns `kept_values`, and recreating
a field with the same `name` brings everything back.

---

## Configuration and helpers

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/crm/config` | `crm_layout` (`standard`, `tms` or `vc`) and the switches for parts of the screen. **Read this before drawing any interface** |
| GET | `/crm/users` | Users who can be assigned to a lead |
| GET | `/crm/tags` | Tenant tags, used in the detection filters |

---

## CRM event webhooks

A **custom integration** (`/integrations/custom`) with a webhook URL and the
`kanban.read` scope receives, in real time:

| Event | When |
|-------|------|
| `crm.lead.created` | Lead created |
| `crm.lead.updated` | Title, phone, email, value, priority, notes, due date, custom fields, assignee or AI score changed |
| `crm.lead.moved` | Lead changed column (carries `from_column_id`) |
| `crm.lead.won` | Lead won |
| `crm.lead.lost` | Lead lost |
| `crm.lead.deleted` | Lead deleted individually |
| `crm.board.deleted` | Board deleted — carries `leads_count` and `columns_count` |

They fire through **any** path: panel, API, web form, import, auto-detection,
automation or AI. These are tenant events and carry no `instance_uuid`. Moves,
wins and losses have their own event and are **not** repeated as
`crm.lead.updated`; routine stamps (`last_activity_at`, `position`, AI counters)
produce no event at all.

**Deleting a board is the one case with no per-lead event.** Its leads and
columns go by database cascade, which never passes through Eloquent — a board
with 5,000 cards would become 5,000 deliveries for a single operator action.
Instead a single `crm.board.deleted` is sent, carrying `leads_count` and
`columns_count`: a mirror drops the whole board and moves on.

Every delivery is signed in `X-Zapini-Signature: sha256=<hmac>` over the raw body
— same as the other webhooks documented at `/developers`.

---

## Error Codes

| Code | HTTP | Description |
|------|------|-------------|
| `NOT_FOUND` | 404 | Resource not found |
| `FORBIDDEN` | 403 | Kanban not enabled for this account |
| `VALIDATION_ERROR` | 422 | Validation failed — check `error.details` |
| `MOVE_FAILED` | 422 | Cannot move lead (e.g. WIP limit reached) |
| `NO_AI_PROVIDER` | 422 | No AI integration configured |
| `INSTANCE_REQUIRED` | 400 | `instance_uuid` required for Sanctum auth |
| `NO_BOARD` | 422 | No Kanban board found for tenant |
| `UNAUTHORIZED` | 401 | Invalid or missing token |
| `SERVER_ERROR` | 500 | Internal server error |
| `INSUFFICIENT_SCOPE` | 403 | The `zpk_` key lacks `kanban.read`/`kanban.write` |
| `INVALID_AUTOMATION_CONFIG` | 422 | The action points at a column or user outside this account |
| `INVALID_FIELD_REFERENCE` | 422 | `show_if`/`label_if`/formula points at a field that does not exist |
| `GLOBAL_FIELD_NOT_REMOVABLE` | 422 | A tenant-global field cannot be removed per board |
| `INVALID_TAG_IDS` | 422 | Tag from another tenant in the detection filters |
| `TICKETS_NOT_ENABLED` | 422 | Tickets integration disabled — cannot convert |
| `LEAD_BOARD_MISMATCH` | 422 | The test lead does not belong to the automation's board |
| `LEAD_CREATE_FAILED` | 422 | Could not create the lead from the given messages |

---

*Generated by Zapini — https://zapini.app*
