# SynQ AI — Company API

Server-to-server API for a SynQ AI company: create and find customers, move them
through the pipeline, open a dialog on a specific agent, and send messages to the
customer's messenger.

Every request carries a company key:

    Authorization: Bearer sk_live_...

The key is minted by the company owner in the CRM (company settings → API tab)
and shown exactly once. It is secret and server-to-server only — never put it in
a browser or any client-side code. Each key carries its own set of scopes; an
endpoint that needs a scope the key lacks answers 403 `insufficient_scope`.

Every error arrives in one envelope:

    {"error": {"code": "wa_window_closed", "message": "...", "details": {}}}

Rate limit: 120 requests per 60 seconds per key. What is left is reported in the
`X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers.

Idempotency: `POST /customers` and `POST /customers/{id}/messages` accept an
`Idempotency-Key` header. A repeat with the same key within 24 hours returns the
stored response without performing the operation again.

Base URL: `https://api.synq.software/ext`

Machine-readable schema: https://synq.software/docs/api/openapi.json
Updated: 2026-08-11

## Agents

### GET /agents
List agents
Scope: `agents:read`
Every agent of the company with the messengers connected to it. The `id` is what you pass as `agent_id` elsewhere.

```bash
curl -X GET 'https://api.synq.software/ext/agents' \
  -H 'Authorization: Bearer sk_live_...'
```
Response 200:

```json
[
  {
    "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "name": "string",
    "is_active": true,
    "messengers": [
      "string"
    ]
  }
]
```

## Customers

### POST /customers
Create or upsert a customer
Scope: `customers:write`
Creates a customer, or returns the existing one (upsert). Deduplication runs on the phone number in E.164 form, on the email, or on any messenger identity. An existing customer comes back with `created:false` and HTTP 200; a new one with `created:true` and HTTP 201.

At least one identifier is required: phone, email or a messenger. The `placement` block picks the pipeline and stage; it is NOT applied on the upsert branch — an existing customer is never relocated.

Two ways to register the customer with a photo in one call.

`fetch_avatar: true` — the simplest: SynQ pulls the photo from the messenger itself, using the agent's own bot credentials. You host nothing and move no bytes, and no Telegram file URL (which embeds a bot token) has to travel anywhere. Call it again later to refresh: an unchanged photo is not re-downloaded, a changed one is. Supported on Telegram, MAX and WhatsApp-personal. On WhatsApp Cloud, Instagram and Facebook the platform either exposes no customer photo or ties the profile read to an open 24-hour window — SynQ checks the window first and makes no call it should not make.

`avatar_url` — an http(s) link you host. Fetched once and stored; the link may expire afterwards.

Either way SynQ serves its own copy, so the response returns a `/media/avatars/...` path rather than your link. The two fields are mutually exclusive. A photo that cannot be fetched never fails the call: the customer is created and `warnings[]` says why it is missing, precisely — `avatar_no_relationship` (they have neither started the bot nor allowed it to write, so the profile is unreadable), `avatar_no_photo` (reachable, but no picture or privacy hides it), `avatar_window_closed`, `avatar_channel_missing`, `avatar_unsupported`, or `avatar_download_failed`. The first two are worth telling apart: one is fixed by the customer opening the bot, the other only by sending a picture of your own. On the upsert branch a new photo replaces the stored one; omitting both fields never wipes it.

Request body:

| Field | Type | Required | Description |
|---|---|---|---|
| `full_name` | `string | null` | no |  |
| `phone` | `string | null` | no |  |
| `email` | `string | null` | no |  |
| `messengers` | `MessengerIn[]` | no |  |
| `source` | `string | null` | no |  |
| `tags` | `string[]` | no |  |
| `variables` | `object<string | integer | number | boolean | string | integer | number | boolean | null[] | ExtVariableIn | null>` | no | Переменные клиента — тот же контракт, что у PATCH .../variables |
| `placement` | `CustomerPlacement | null` | no |  |
| `language` | `string | null` | no | Preferred language, ISO 639-1 |
| `avatar_url` | `string | null` | no | http(s) URL of the customer's photo. SynQ DOWNLOADS it once and serves its own copy — the link may expire afterwards, and the operator's browser never talks to your host. The response returns the stored path, not the URL you sent. A photo that cannot be fetched is skipped: the customer is still created, just without an avatar. |
| `fetch_avatar` | `boolean` | no | Let SynQ pull the photo from the messenger itself, using the agent's own bot credentials. Nothing to host and no bytes to move: you already told us who the customer is. Works for a customer who has started the bot; if they have no photo or privacy hides it, the customer is created without one. Call it again later to refresh — an unchanged photo is not re-downloaded. Telegram, MAX and WhatsApp-personal support this; other messengers ignore it. Mutually exclusive with avatar_url. |

```bash
curl -X POST 'https://api.synq.software/ext/customers' \
  -H 'Authorization: Bearer sk_live_...' \
  -H 'Content-Type: application/json' \
  -d '{"full_name":"string","phone":"string","email":"string","messengers":[{"type":"string","id":"string","username":null}],"source":"string","tags":["string"],"variables":{"key":"string"},"placement":{"agent_id":null,"pipeline_id":null,"stage_code":null},"language":"string","avatar_url":"string","fetch_avatar":false}'
```
Response 201:

```json
{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "created": true,
  "full_name": "string",
  "phone": "string",
  "email": "string",
  "avatar_url": "string",
  "messengers": [
    {
      "type": "string",
      "id": "string",
      "username": null
    }
  ],
  "deal": {
    "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "pipeline_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "stage_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "stage_code": null
  },
  "warnings": [
    {
      "code": "string",
      "message": "string"
    }
  ]
}
```

### GET /customers/lookup
Find a customer without knowing its id
Scope: `customers:read`
Pass one of the sets: `messenger_type` together with `messenger_id`, or `phone`, or `email`.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `messenger_type` | query | `string | null` | no |  |
| `messenger_id` | query | `string | null` | no |  |
| `phone` | query | `string | null` | no |  |
| `email` | query | `string | null` | no |  |

```bash
curl -X GET 'https://api.synq.software/ext/customers/lookup' \
  -H 'Authorization: Bearer sk_live_...'
```
Response 200:

```json
{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "full_name": "string",
  "phone": "string",
  "email": "string",
  "avatar_url": "string",
  "current_stage": "string",
  "messengers": [
    {
      "type": "string",
      "id": "string",
      "username": null
    }
  ],
  "deal": {
    "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "pipeline_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "stage_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "stage_code": null
  }
}
```

### GET /customers/{customer_id}
Get a customer
Scope: `customers:read`
The customer with its current pipeline stage and messenger identities.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `customer_id` | path | `uuid` | yes |  |

```bash
curl -X GET 'https://api.synq.software/ext/customers/<customer_id>' \
  -H 'Authorization: Bearer sk_live_...'
```
Response 200:

```json
{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "full_name": "string",
  "phone": "string",
  "email": "string",
  "avatar_url": "string",
  "current_stage": "string",
  "messengers": [
    {
      "type": "string",
      "id": "string",
      "username": null
    }
  ],
  "deal": {
    "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "pipeline_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "stage_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "stage_code": null
  }
}
```

### POST /customers/{customer_id}/conversations
Open a dialog with the customer on a specific agent
Scope: `customers:write`
Opens a dialog without messaging the customer. This is the server-side twin of the CRM's «operator writes first» button: the customer card gets a chat an operator can open and type into.

Idempotent by construction: an ACTIVE dialog for this (customer, agent) pair is returned as-is with `created:false`. No second dialog is opened and the timeline is not touched — calling again is safe.

A freshly created dialog gets one system message, «Conversation created via API». Without it the dialog has no `last_message_at`, and the operator's list — sorted by that field — pushes it to the very bottom, which reads as if it were never created.

Omit `agent_id` only when the customer already has a dialog or a deal to inherit the agent from. In a company with several agents, pass it explicitly.

**Can you actually reach this customer?** The response answers that without you asking: `customer_blocked` is true when they have blocked the bot. You send nothing about it — Telegram reports a block to SynQ and we keep the state, so this is our own knowledge, as fresh as the last thing the messenger told us. A newly created dialog also gets the fact stamped into its timeline, so an operator opening the chat sees the wall before typing into it; an existing dialog already carries its own block event and is not re-stamped on every call.

Telegram-only, by construction: it is the surface that reports the fact. On WhatsApp, Instagram and the rest a block stays invisible until a send fails, and probing for it would mean querying those APIs about people who never wrote to us — the traffic they punish.

**Sending an operator straight there.** The response carries `crm_url` — a deep link that opens this exact dialog in the CRM. Use it instead of assembling the address yourself: the CRM host differs between environments and has already moved once, and a link built from the marketing apex lands on a redirect stub rather than the chat. The `id` field is the conversation id if you need it on its own.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `customer_id` | path | `uuid` | yes |  |

Request body:

| Field | Type | Required | Description |
|---|---|---|---|
| `agent_id` | `uuid | null` | no | The agent (channel) the dialog belongs to. Omit only when the customer already has a dialog or a deal to inherit the agent from; in a company with several agents, pass it explicitly. |

```bash
curl -X POST 'https://api.synq.software/ext/customers/<customer_id>/conversations' \
  -H 'Authorization: Bearer sk_live_...' \
  -H 'Content-Type: application/json' \
  -d '{"agent_id":"3fa85f64-5717-4562-b3fc-2c963f66afa6"}'
```
Response 200:

```json
{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "channel_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "crm_url": "string",
  "customer_blocked": false,
  "warnings": [
    {
      "code": "string",
      "message": "string"
    }
  ],
  "created": true
}
```

### GET /customers/{customer_id}/deals
List the customer's deals
Scope: `customers:read`
Every deal of the customer, closed ones (won/lost) included — this is how you check whether they have been through the pipeline before.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `customer_id` | path | `uuid` | yes |  |
| `pipeline_id` | query | `uuid | null` | no |  |

```bash
curl -X GET 'https://api.synq.software/ext/customers/<customer_id>/deals' \
  -H 'Authorization: Bearer sk_live_...'
```
Response 200:

```json
{
  "deals": [
    {
      "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "pipeline_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "stage_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "stage_code": null,
      "title": null,
      "status": "string",
      "closed_reason": null,
      "created_at": null,
      "moved_at": null
    }
  ]
}
```

### POST /customers/{customer_id}/deals
Create a deal with explicit placement
Scope: `customers:write`
`409 conflict` means an active deal already exists in that pipeline: one active deal per pipeline is the rule.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `customer_id` | path | `uuid` | yes |  |

Request body:

| Field | Type | Required | Description |
|---|---|---|---|
| `pipeline_id` | `uuid | null` | no |  |
| `stage_id` | `uuid | null` | no |  |
| `stage_code` | `string | null` | no |  |
| `agent_id` | `uuid | null` | no |  |
| `title` | `string | null` | no |  |

```bash
curl -X POST 'https://api.synq.software/ext/customers/<customer_id>/deals' \
  -H 'Authorization: Bearer sk_live_...' \
  -H 'Content-Type: application/json' \
  -d '{"pipeline_id":"3fa85f64-5717-4562-b3fc-2c963f66afa6","stage_id":"3fa85f64-5717-4562-b3fc-2c963f66afa6","stage_code":"string","agent_id":"3fa85f64-5717-4562-b3fc-2c963f66afa6","title":"string"}'
```
Response 201:

```json
{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "pipeline_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "stage_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "stage_code": "string",
  "title": "string",
  "status": "string",
  "closed_reason": "string",
  "created_at": "2026-08-09T12:00:00Z",
  "moved_at": "2026-08-09T12:00:00Z"
}
```

### POST /customers/{customer_id}/merge
Merge two customer cards into one
Scope: `customers:write`
One person can arrive twice: they take a promo code on Instagram and then talk to you on Telegram. For us that is two cards, and often the only thing tying them together is the code that was issued — so the donor card may be named by `promo_code` instead of `from_customer_id`.

The winner is the card in the path. Its own ids never change, which is why you should point the call at the card your integration already writes to. Messengers, conversations, deals, variables, issued codes, keyword hits and notes move onto it; fields already filled on the winner are never overwritten — only the gaps are taken from the donor. The donor is not deleted (its conversation history hangs off it), it is flagged as merged and deactivated.

**Conversations.** A conversation belongs to a (customer, agent) pair and does not distinguish messengers — every messenger of that agent writes into one thread, and each message carries its own source. So messages from a donor thread **on the same agent** are always merged into the surviving thread: two live threads on one agent would make sending pick between them, and archiving one would orphan its messages. Crossing to a **different agent** happens only with `absorb_conversations: true`, because that agent has its own prompt and its own pipeline. `keep_conversation_id` names which thread survives; by default it is the winner's.

Whatever could not be moved is reported in `warnings` rather than passed over in silence: a second code from the same pool stays with the donor (one code per customer per pool is enforced by the database, and the customer already holds it), a second active deal in the same pipeline is closed as a duplicate, and same-named variables stay with the winner.

Repeating the call is safe: an already merged donor returns `already_merged: true` and moves nothing — including when the donor is looked up by a code that now belongs to the winner.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `customer_id` | path | `uuid` | yes |  |

Request body:

| Field | Type | Required | Description |
|---|---|---|---|
| `from_customer_id` | `uuid | null` | no | Карточка-донор — она будет слита в основную |
| `promo_code` | `string | null` | no | Донор = клиент, которому выдан этот код (альтернатива id) |
| `keep_conversation_id` | `uuid | null` | no | Какой диалог остаётся активным на своём агенте. По умолчанию — диалог основной карточки: его id уже сохранён во внешнем сервисе |
| `absorb_conversations` | `boolean` | no | Склеить переписку в ОДНУ ленту: сообщения диалогов донора переезжают в целевой диалог, опустевшие уходят в архив. Источник каждого сообщения остаётся виден иконкой мессенджера |

```bash
curl -X POST 'https://api.synq.software/ext/customers/<customer_id>/merge' \
  -H 'Authorization: Bearer sk_live_...' \
  -H 'Content-Type: application/json' \
  -d '{"from_customer_id":"3fa85f64-5717-4562-b3fc-2c963f66afa6","promo_code":"string","keep_conversation_id":"3fa85f64-5717-4562-b3fc-2c963f66afa6","absorb_conversations":false}'
```
Response 200:

```json
{
  "winner_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "donor_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "already_merged": false,
  "messengers_moved": 0,
  "conversations_moved": 0,
  "conversations_archived": 0,
  "conversations_dropped": 0,
  "messages_moved": 0,
  "deals_moved": 0,
  "deals_closed_as_duplicate": 0,
  "variables_moved": 0,
  "promo_codes_moved": 0,
  "notes_moved": 0,
  "warnings": [
    "string"
  ]
}
```

### POST /customers/{customer_id}/messages
Send a message to the customer
Scope: depends on the request body — see the note.
Sends a message to a specific messenger of the customer. Free-form text needs the `messages:send` scope, a WhatsApp template needs `messages:template` — which one applies is decided by the request body.

**Formatting: write Markdown, once, for every messenger.** You do not adapt the text per platform — SynQ renders it on the way out, the same pipeline the AI agent's own replies go through.

Parsed: `**bold**`, `*italic*`, `***bold italic***`, `~~strikethrough~~`, `` `code` ``, and `[label](https://example.com)`.

Not parsed, and delivered literally: `_underscores_` and `__double underscores__` (use `*` for italic, not `_`), bullet lists, tables, blockquotes, and `#` headings.

**Keep the opening marker glued to the text.** `**Заголовок**` is bold; `** Заголовок**` — with a space after the asterisks — is not markup at all by the standard, and different renderers disagree about it. Same for the closing marker. Lists are fine as plain text — they simply arrive as the characters you typed.

What each messenger receives:

| Messenger | Rendering |
| --- | --- |
| Telegram, Telegram-personal, web widget | Markdown converted to the platform's own formatting; `[label](url)` becomes a real hyperlink. If the platform rejects the markup for any reason, the message is re-sent as plain text rather than lost. |
| WhatsApp | WhatsApp syntax: `*bold*`, `_italic_`, `~strike~`, `` ```code``` ``. Links are flattened to «label: url» — WhatsApp autolinks a bare URL. |
| Instagram, Facebook, VK, MAX | Markup removed, links flattened to «label: url». These surfaces autolink a bare URL and show any other marker as literal characters. |

A practical consequence: keep the same text short and marker-light if it may go to several channels. What renders as bold on Telegram is just words on Instagram.

The 24-hour window: on WhatsApp, Instagram and Facebook, free-form text outside the window is forbidden by Meta's rules, so a template is required. The window is anchored to the last INBOUND message from the customer. Telegram has no such window, but a bot cannot write first to someone who never pressed Start — that call returns `409 no_peer`.

The optional `follow_up` block schedules a reminder: «send now, and nudge in N minutes if the customer stays silent». Any reply from the customer cancels the reminder on its own; there is no separate cancel call.

**Does sending pause the agent?** Only if you ask. By default `pause_agent` is false and the AI keeps running — which is what a notification needs: you tell the customer something, they reply, and the agent handles it. Set `pause_agent: true` when a human is taking the dialog over from your side; that stops the agent exactly like the pause button in the CRM and leaves a pause card in the timeline saying it came from the API. The pause is applied only after the message is confirmed sent — stopping the agent on a message that never left would hand the dialog to someone who does not know they now own it.

This is deliberately independent of the agent's `auto_pause_on_human` setting. That one fires when a human replies inside the CRM; an API call cannot tell a notification from a person typing, so it asks you rather than guessing.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `customer_id` | path | `uuid` | yes |  |

Request body:

| Field | Type | Required | Description |
|---|---|---|---|
| `messenger_type` | `string` | yes |  |
| `agent_id` | `uuid | null` | no |  |
| `text` | `string | null` | no |  |
| `template` | `TemplateSendIn | null` | no |  |
| `follow_up` | `FollowUpIn | null` | no |  |
| `pause_agent` | `boolean` | no | Stop the AI agent in this dialog after the message goes out. Default false, which is what a notification wants: you tell the customer something and the agent keeps handling whatever they reply. Set true when a HUMAN is taking the conversation over through your own panel — the same effect as the pause button in the CRM, including the pause card in the timeline. Note this does NOT consult the agent's auto_pause_on_human setting: that one is about a human replying inside the CRM, and an API call cannot tell a notification from a person typing, so it asks you instead of guessing. |

```bash
curl -X POST 'https://api.synq.software/ext/customers/<customer_id>/messages' \
  -H 'Authorization: Bearer sk_live_...' \
  -H 'Content-Type: application/json' \
  -d '{"messenger_type":"string","agent_id":"3fa85f64-5717-4562-b3fc-2c963f66afa6","text":"string","template":{"name":"string","lang":"string","variables":[null],"button_param":null},"follow_up":{"after_minutes":0,"text":null,"template":null},"pause_agent":false}'
```
Response 200:

```json
{
  "message_id": "string",
  "status": "string",
  "messenger_type": "string",
  "follow_up": {
    "job_id": "string",
    "scheduled_at": "2026-08-09T12:00:00Z"
  }
}
```

### GET /customers/{customer_id}/messengers
List the customer's messengers
Scope: `customers:read`
Each messenger identity together with its sending window: whether free-form text is allowed right now and when the window closes.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `customer_id` | path | `uuid` | yes |  |

```bash
curl -X GET 'https://api.synq.software/ext/customers/<customer_id>/messengers' \
  -H 'Authorization: Bearer sk_live_...'
```
Response 200:

```json
{
  "messengers": [
    {
      "type": "string",
      "id": "string",
      "username": null,
      "last_inbound_at": null,
      "can_send_freeform": true,
      "window_expires_at": null
    }
  ]
}
```

### POST /customers/{customer_id}/stage
Move the customer's deal to another stage
Scope: `customers:write`
Pass exactly one of `stage_id` or `stage_code`. A stage outside the customer's pipeline returns `409` — membership is checked through the agent.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `customer_id` | path | `uuid` | yes |  |

Request body:

| Field | Type | Required | Description |
|---|---|---|---|
| `stage_id` | `uuid | null` | no |  |
| `stage_code` | `string | null` | no |  |
| `reason` | `string | null` | no |  |

```bash
curl -X POST 'https://api.synq.software/ext/customers/<customer_id>/stage' \
  -H 'Authorization: Bearer sk_live_...' \
  -H 'Content-Type: application/json' \
  -d '{"stage_id":"3fa85f64-5717-4562-b3fc-2c963f66afa6","stage_code":"string","reason":"string"}'
```
Response 200:

```json
{
  "deal_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "stage_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "stage_code": "string",
  "moved_at": "2026-08-09T12:00:00Z"
}
```

### GET /customers/{customer_id}/variables
Read customer variables
Scope: `customers:read`
Every variable stored on the customer, with its type and — for multi-valued ones — the list of elements. The response — for both GET and PATCH — is every variable of the customer or deal: `name`, `value`, `type`, `is_list`, `value_list`, `group_name`, `updated_at`.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `customer_id` | path | `uuid` | yes |  |

```bash
curl -X GET 'https://api.synq.software/ext/customers/<customer_id>/variables' \
  -H 'Authorization: Bearer sk_live_...'
```
Response 200:

```json
{
  "variables": [
    {
      "name": "string",
      "value": null,
      "type": "text",
      "is_list": false,
      "value_list": null,
      "group_name": null,
      "updated_at": null
    }
  ]
}
```

### PATCH /customers/{customer_id}/variables
Write customer variables
Scope: `customers:write`
Variables are addressed **by name** — the id of a variable is not something a caller can know.

A value may be given in short form (`{"visits": 12}`) or in full form (`{"visits": {"value": 12, "type": "number"}}`), mixed freely in one request. Variables you do not mention are left untouched: this is a patch, not a replacement of the whole set. Up to 50 per call.

**Types** are the same vocabulary the agent's collection fields use: `text`, `select`, `datetime`, `number`, `date`, `time`, `phone`, `email`. Omit the type and an existing variable keeps its own, while a new one takes it from the JSON type — a JSON number becomes `number`, a string stays `text`. A quoted `"12"` therefore stays text: guessing the type from the contents would eventually turn the part number `00123` into `123`. Values are canonicalised the same way the agent canonicalises what a customer answers, so `26.08.2026` is stored as `2026-08-26`. A value that does not fit its type is rejected with `422 validation_error` naming the variable.

The type is not decoration: it decides which operators the audience builder offers. A number stored as text gives no «greater than» condition.

**Lists** are set with `mode`: `replace` (default), `append`, `remove`. Pass an array, or append one element at a time — a caller sending today's promo code does not need to know the whole history. Append deduplicates and keeps the order. A list holds one type of element, named in `type`. Its `value` stays filled with a readable join («RENT-A1, RENT-A2»), so consumers that know nothing about lists keep reading a single field.

`null` in short form **deletes** the variable; `{"value": null}` clears the value and keeps the field.

The response — for both GET and PATCH — is every variable of the customer or deal: `name`, `value`, `type`, `is_list`, `value_list`, `group_name`, `updated_at`.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `customer_id` | path | `uuid` | yes |  |

Request body:

| Field | Type | Required | Description |
|---|---|---|---|
| `variables` | `object<string | integer | number | boolean | string | integer | number | boolean | null[] | ExtVariableIn | null>` | yes | Имя → значение (короткая форма) либо объект {value, type, mode}. Значение null удаляет переменную |

```bash
curl -X PATCH 'https://api.synq.software/ext/customers/<customer_id>/variables' \
  -H 'Authorization: Bearer sk_live_...' \
  -H 'Content-Type: application/json' \
  -d '{"variables":{"key":"string"}}'
```
Response 200:

```json
{
  "variables": [
    {
      "name": "string",
      "value": null,
      "type": "text",
      "is_list": false,
      "value_list": null,
      "group_name": null,
      "updated_at": null
    }
  ]
}
```

## Deals

### POST /deals/{deal_id}/close
Close a deal
Scope: `customers:write`
Marks the deal won or lost with an optional reason.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `deal_id` | path | `uuid` | yes |  |

Request body:

| Field | Type | Required | Description |
|---|---|---|---|
| `status` | `string` | yes | won | lost |
| `reason` | `string | null` | no |  |

```bash
curl -X POST 'https://api.synq.software/ext/deals/<deal_id>/close' \
  -H 'Authorization: Bearer sk_live_...' \
  -H 'Content-Type: application/json' \
  -d '{"status":"string","reason":"string"}'
```
Response 200:

```json
{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "pipeline_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "stage_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "stage_code": "string",
  "title": "string",
  "status": "string",
  "closed_reason": "string",
  "created_at": "2026-08-09T12:00:00Z",
  "moved_at": "2026-08-09T12:00:00Z"
}
```

### GET /deals/{deal_id}/variables
Read deal variables
Scope: `customers:read`
Every variable stored on the deal. Same shape as the customer endpoint. The response — for both GET and PATCH — is every variable of the customer or deal: `name`, `value`, `type`, `is_list`, `value_list`, `group_name`, `updated_at`.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `deal_id` | path | `uuid` | yes |  |

```bash
curl -X GET 'https://api.synq.software/ext/deals/<deal_id>/variables' \
  -H 'Authorization: Bearer sk_live_...'
```
Response 200:

```json
{
  "variables": [
    {
      "name": "string",
      "value": null,
      "type": "text",
      "is_list": false,
      "value_list": null,
      "group_name": null,
      "updated_at": null
    }
  ]
}
```

### PATCH /deals/{deal_id}/variables
Write deal variables
Scope: `customers:write`
Same contract as customer variables, applied to a deal.

Variables are addressed **by name** — the id of a variable is not something a caller can know.

A value may be given in short form (`{"visits": 12}`) or in full form (`{"visits": {"value": 12, "type": "number"}}`), mixed freely in one request. Variables you do not mention are left untouched: this is a patch, not a replacement of the whole set. Up to 50 per call.

**Types** are the same vocabulary the agent's collection fields use: `text`, `select`, `datetime`, `number`, `date`, `time`, `phone`, `email`. Omit the type and an existing variable keeps its own, while a new one takes it from the JSON type — a JSON number becomes `number`, a string stays `text`. A quoted `"12"` therefore stays text: guessing the type from the contents would eventually turn the part number `00123` into `123`. Values are canonicalised the same way the agent canonicalises what a customer answers, so `26.08.2026` is stored as `2026-08-26`. A value that does not fit its type is rejected with `422 validation_error` naming the variable.

The type is not decoration: it decides which operators the audience builder offers. A number stored as text gives no «greater than» condition.

**Lists** are set with `mode`: `replace` (default), `append`, `remove`. Pass an array, or append one element at a time — a caller sending today's promo code does not need to know the whole history. Append deduplicates and keeps the order. A list holds one type of element, named in `type`. Its `value` stays filled with a readable join («RENT-A1, RENT-A2»), so consumers that know nothing about lists keep reading a single field.

`null` in short form **deletes** the variable; `{"value": null}` clears the value and keeps the field.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `deal_id` | path | `uuid` | yes |  |

Request body:

| Field | Type | Required | Description |
|---|---|---|---|
| `variables` | `object<string | integer | number | boolean | string | integer | number | boolean | null[] | ExtVariableIn | null>` | yes | Имя → значение (короткая форма) либо объект {value, type, mode}. Значение null удаляет переменную |

```bash
curl -X PATCH 'https://api.synq.software/ext/deals/<deal_id>/variables' \
  -H 'Authorization: Bearer sk_live_...' \
  -H 'Content-Type: application/json' \
  -d '{"variables":{"key":"string"}}'
```
Response 200:

```json
{
  "variables": [
    {
      "name": "string",
      "value": null,
      "type": "text",
      "is_list": false,
      "value_list": null,
      "group_name": null,
      "updated_at": null
    }
  ]
}
```

## Pipelines

### GET /pipelines
List pipelines and their stages
Scope: `pipelines:read`
Optionally filtered by `agent_id`. Each stage carries a `stage_code` — the stable handle to use in `placement` and stage moves instead of a UUID.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `agent_id` | query | `uuid | null` | no |  |

```bash
curl -X GET 'https://api.synq.software/ext/pipelines' \
  -H 'Authorization: Bearer sk_live_...'
```
Response 200:

```json
[
  {
    "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "name": "string",
    "agent_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "is_default": true,
    "stages": [
      {
        "id": null,
        "name": null,
        "stage_code": null,
        "position": null
      }
    ]
  }
]
```

## promo-codes

### GET /promo-codes/{code}
Look up who a code was issued to
Scope: `promo:read`
Searches every pool of the company: you know the code, not which pool it came from.

This is the bridge between two systems. Your service identifies a person by its own key — usually a Telegram id — while the code may well have been handed out in another messenger. The response carries the customer together with all of their messengers, so you can decide what to do next: write a variable, or merge the two cards.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `code` | path | `string` | yes |  |

```bash
curl -X GET 'https://api.synq.software/ext/promo-codes/<code>' \
  -H 'Authorization: Bearer sk_live_...'
```
Response 200:

```json
{
  "code": "string",
  "pool_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "pool_title": "string",
  "customer_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "customer_name": "string",
  "messengers": [
    {
      "type": "string",
      "id": "string",
      "username": null
    }
  ],
  "reserved_at": "2026-08-09T12:00:00Z",
  "issued_at": "2026-08-09T12:00:00Z",
  "revoked_at": "2026-08-09T12:00:00Z"
}
```

## promo-pools

### GET /promo-pools
List promo-code pools
Scope: `promo:read`
Codes are minted by your service and handed out by the agent, so «how many are left and when to top up» is asked from the same place the codes come from. `remaining` is counted from the actual rows rather than a running counter, and `low_threshold` comes back already computed, so your monitor fires at the same number our own alert does. `exhausted_requests` is how many customers asked for a code and found the pool empty — the price of the outage.

Optional `agent_id` narrows the list to one agent.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `agent_id` | query | `uuid | null` | no | Только пулы этого агента |

```bash
curl -X GET 'https://api.synq.software/ext/promo-pools' \
  -H 'Authorization: Bearer sk_live_...'
```
Response 200:

```json
{
  "pools": [
    {
      "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "agent_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "title": "string",
      "description": null,
      "is_enabled": true,
      "total": 0,
      "issued": 0,
      "remaining": 0,
      "low_threshold": 0,
      "low_alert_sent_at": null,
      "empty_alert_sent_at": null,
      "exhausted_requests": 0,
      "code_word_titles": [
        null
      ],
      "created_at": null
    }
  ]
}
```

### GET /promo-pools/{pool_id}
Read one promo-code pool
Scope: `promo:read`
Totals, remaining codes, alert threshold and the keywords that hand out from this pool.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `pool_id` | path | `uuid` | yes |  |

```bash
curl -X GET 'https://api.synq.software/ext/promo-pools/<pool_id>' \
  -H 'Authorization: Bearer sk_live_...'
```
Response 200:

```json
{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "agent_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "title": "string",
  "description": "string",
  "is_enabled": true,
  "total": 0,
  "issued": 0,
  "remaining": 0,
  "low_threshold": 0,
  "low_alert_sent_at": "2026-08-09T12:00:00Z",
  "empty_alert_sent_at": "2026-08-09T12:00:00Z",
  "exhausted_requests": 0,
  "code_word_titles": [
    "string"
  ],
  "created_at": "2026-08-09T12:00:00Z"
}
```

### PATCH /promo-pools/{pool_id}
Update a pool (including stopping the giveaway)
Scope: `promo:write`
Only the fields you pass are changed; the rest are left alone. Counters are not settings and cannot be set here.

`is_enabled: false` is the fastest way to stop the giveaway altogether: a disabled pool issues nothing at all and the agent switches to `exhausted_instruction` immediately. The codes stay where they are — the same call turns it back on.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `pool_id` | path | `uuid` | yes |  |

Request body:

| Field | Type | Required | Description |
|---|---|---|---|
| `is_enabled` | `boolean | null` | no | Выключенный пул не выдаёт коды вообще — самый быстрый способ остановить раздачу, не трогая сами коды |
| `title` | `string | null` | no |  |
| `description` | `string | null` | no |  |
| `low_threshold_pct` | `integer | null` | no |  |
| `low_threshold_min` | `integer | null` | no |  |
| `strict_follow_gate` | `boolean | null` | no |  |
| `exhausted_instruction` | `string | null` | no | Что агенту предлагать клиенту, когда коды кончились. Инструкция по смыслу, а не готовый текст — уходит на языке клиента |

```bash
curl -X PATCH 'https://api.synq.software/ext/promo-pools/<pool_id>' \
  -H 'Authorization: Bearer sk_live_...' \
  -H 'Content-Type: application/json' \
  -d '{"is_enabled":true,"title":"string","description":"string","low_threshold_pct":0,"low_threshold_min":0,"strict_follow_gate":true,"exhausted_instruction":"string"}'
```
Response 200:

```json
{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "agent_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "title": "string",
  "description": "string",
  "is_enabled": true,
  "total": 0,
  "issued": 0,
  "remaining": 0,
  "low_threshold": 0,
  "low_alert_sent_at": "2026-08-09T12:00:00Z",
  "empty_alert_sent_at": "2026-08-09T12:00:00Z",
  "exhausted_requests": 0,
  "code_word_titles": [
    "string"
  ],
  "created_at": "2026-08-09T12:00:00Z"
}
```

### GET /promo-pools/{pool_id}/codes
List the codes of a pool
Scope: `promo:read`
The issue history: which code went to whom and when. `issued_only` keeps only the handed-out ones, `search` matches part of a code — a customer writing «my code does not work» is looked up by exactly that.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `pool_id` | path | `uuid` | yes |  |
| `issued_only` | query | `boolean` | no | Только выданные |
| `search` | query | `string | null` | no | Поиск по коду |
| `limit` | query | `integer` | no |  |
| `offset` | query | `integer` | no |  |

```bash
curl -X GET 'https://api.synq.software/ext/promo-pools/<pool_id>/codes' \
  -H 'Authorization: Bearer sk_live_...'
```
Response 200:

```json
{
  "codes": [
    {
      "code": "string",
      "position": 0,
      "customer_id": null,
      "reserved_at": null,
      "issued_at": null,
      "revoked_at": null
    }
  ]
}
```

### POST /promo-pools/{pool_id}/codes
Top up a pool with codes
Scope: `promo:write`
Sending the same list twice is safe. Deduplication happens in two layers, both inside the loader: within the submitted list, and against what is already stored — the latter by a unique `(pool_id, code)` index with `ON CONFLICT DO NOTHING`, which is database physics rather than a check in code, so parallel uploads cannot duplicate either. A repeat returns `accepted: 0` instead of doubling the pool, which is why no idempotency header is needed here.

Deduplication is **per pool**: the same code in another pool goes through, since pools are independent campaigns. New codes join the end of the queue — already loaded ones are handed out first. Up to 10 000 per call. The loader also clears the alert flags once the pool is back above its threshold; without that the second time it emptied would pass unnoticed.

The report comes back whole — without the split between accepted, repeats inside the file and codes already in the pool, someone who uploads a file twice believes they have twice as many codes.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `pool_id` | path | `uuid` | yes |  |

Request body:

| Field | Type | Required | Description |
|---|---|---|---|
| `codes` | `string[]` | yes | Коды, по одному в элементе списка |

```bash
curl -X POST 'https://api.synq.software/ext/promo-pools/<pool_id>/codes' \
  -H 'Authorization: Bearer sk_live_...' \
  -H 'Content-Type: application/json' \
  -d '{"codes":["string"]}'
```
Response 200:

```json
{
  "accepted": 0,
  "duplicates_in_input": 0,
  "already_in_pool": 0,
  "total": 0,
  "remaining": 0
}
```

### POST /promo-pools/{pool_id}/codes/revoke
Revoke codes
Scope: `promo:write`
Retire codes that no longer work on your side — a batch was cancelled, a campaign closed, a code leaked. Until now the loader could only add, so a pool kept handing out what was already dead on your side and the customer walked into «this promo code is not active».

A code that was **never handed out** simply leaves the queue. A code **already in a customer's hands** stops counting as their code: the next time they ask, the agent hands them a replacement instead of the dead one. Issuing is idempotent, so before this it would have returned the very same code again.

Rows are not deleted — they are the issue history a complaint is investigated against, and the code the customer holds does not disappear because we forgot it. Repeating the call is safe: codes already revoked come back under `already_revoked` instead of being revoked twice.

To undo, upload the codes again: a revoked code that was never handed out returns to the queue.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `pool_id` | path | `uuid` | yes |  |

Request body:

| Field | Type | Required | Description |
|---|---|---|---|
| `codes` | `string[]` | yes | Коды, которые больше не действуют |

```bash
curl -X POST 'https://api.synq.software/ext/promo-pools/<pool_id>/codes/revoke' \
  -H 'Authorization: Bearer sk_live_...' \
  -H 'Content-Type: application/json' \
  -d '{"codes":["string"]}'
```
Response 200:

```json
{
  "revoked": 0,
  "issued_revoked": 0,
  "already_revoked": 0,
  "not_found": [
    "string"
  ],
  "total": 0,
  "remaining": 0
}
```

## WhatsApp templates

### GET /whatsapp/templates
List WhatsApp templates
Scope: depends on the request body — see the note.
The agent's WhatsApp templates — a cached proxy to Meta's registry. A template's status is APPROVED, PENDING or REJECTED. Either `messages:template` or `agents:read` is enough.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `agent_id` | query | `uuid | null` | no |  |

```bash
curl -X GET 'https://api.synq.software/ext/whatsapp/templates' \
  -H 'Authorization: Bearer sk_live_...'
```
Response 200:

```json
{
  "templates": [
    {
      "name": "string",
      "status": null,
      "category": null,
      "language": null,
      "variables": [
        null
      ],
      "has_buttons": false
    }
  ]
}
```

### POST /whatsapp/templates
Create a WhatsApp template
Scope: `templates:manage`
The call does not clear Meta's moderation: the template is queued as PENDING and later becomes APPROVED or REJECTED.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `agent_id` | query | `uuid | null` | no |  |

Request body:

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | `string` | yes |  |
| `category` | `string` | yes | MARKETING | UTILITY | AUTHENTICATION |
| `language` | `string` | yes |  |
| `components` | `object<any>[]` | yes | Meta component array (header/body/footer/buttons) |

```bash
curl -X POST 'https://api.synq.software/ext/whatsapp/templates' \
  -H 'Authorization: Bearer sk_live_...' \
  -H 'Content-Type: application/json' \
  -d '{"name":"string","category":"string","language":"string","components":[{"key":"string"}]}'
```
Response 201:

```json
{
  "key": "string"
}
```

