SYNQ AI API Markdown OpenAPI

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 https://api.synq.software/ext

Agents

GET /agents

List agents

Requires scope: agents:read

Every agent of the company with the messengers connected to it. The id is what you pass as agent_id elsewhere.

Example

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

Response 200

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

Customers

POST /customers

Create or upsert a customer

Requires 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

FieldTypeDescription
full_name string | null
phone string | null
email string | null
messengers MessengerIn[]
source string | null
tags string[]
variables object<string | integer | number | boolean | string | integer | number | boolean | null[] | ExtVariableIn | null> Переменные клиента — тот же контракт, что у PATCH .../variables
placement CustomerPlacement | null
language string | null Preferred language, ISO 639-1
avatar_url string | null 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 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.

Example

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

{
  "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"
    }
  ]
}

Errors: 422 — in the shared envelope {"error": {...}}.

GET /customers/lookup

Find a customer without knowing its id

Requires scope: customers:read

Pass one of the sets: messenger_type together with messenger_id, or phone, or email.

ParameterInTypeDescription
messenger_type query string | null
messenger_id query string | null
phone query string | null
email query string | null

Example

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

Response 200

{
  "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
  }
}

Errors: 422 — in the shared envelope {"error": {...}}.

GET /customers/{customer_id}

Get a customer

Requires scope: customers:read

The customer with its current pipeline stage and messenger identities.

ParameterInTypeDescription
customer_idrequired path uuid

Example

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

Response 200

{
  "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
  }
}

Errors: 422 — in the shared envelope {"error": {...}}.

POST /customers/{customer_id}/conversations

Open a dialog with the customer on a specific agent

Requires 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.

ParameterInTypeDescription
customer_idrequired path uuid

Request body

FieldTypeDescription
agent_id uuid | null 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.

Example

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

{
  "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
}

Errors: 422 — in the shared envelope {"error": {...}}.

GET /customers/{customer_id}/deals

List the customer's deals

Requires 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.

ParameterInTypeDescription
customer_idrequired path uuid
pipeline_id query uuid | null

Example

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

Response 200

{
  "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
    }
  ]
}

Errors: 422 — in the shared envelope {"error": {...}}.

POST /customers/{customer_id}/deals

Create a deal with explicit placement

Requires scope: customers:write

409 conflict means an active deal already exists in that pipeline: one active deal per pipeline is the rule.

ParameterInTypeDescription
customer_idrequired path uuid

Request body

FieldTypeDescription
pipeline_id uuid | null
stage_id uuid | null
stage_code string | null
agent_id uuid | null
title string | null

Example

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

{
  "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"
}

Errors: 422 — in the shared envelope {"error": {...}}.

POST /customers/{customer_id}/merge

Merge two customer cards into one

Requires 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.

ParameterInTypeDescription
customer_idrequired path uuid

Request body

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

Example

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

{
  "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,
  "messages_moved": 0,
  "deals_moved": 0,
  "deals_closed_as_duplicate": 0,
  "variables_moved": 0,
  "promo_codes_moved": 0,
  "notes_moved": 0,
  "warnings": [
    "string"
  ]
}

Errors: 422 — in the shared envelope {"error": {...}}.

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:

MessengerRendering
Telegram, Telegram-personal, web widgetMarkdown 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.
WhatsAppWhatsApp syntax: *bold*, _italic_, ~strike~, `code` . Links are flattened to «label: url» — WhatsApp autolinks a bare URL.
Instagram, Facebook, VK, MAXMarkup 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.

ParameterInTypeDescription
customer_idrequired path uuid

Request body

FieldTypeDescription
messenger_typerequired string
agent_id uuid | null
text string | null
template TemplateSendIn | null
follow_up FollowUpIn | null
pause_agent boolean 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.

Example

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

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

Errors: 422 — in the shared envelope {"error": {...}}.

GET /customers/{customer_id}/messengers

List the customer's messengers

Requires scope: customers:read

Each messenger identity together with its sending window: whether free-form text is allowed right now and when the window closes.

ParameterInTypeDescription
customer_idrequired path uuid

Example

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

Response 200

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

Errors: 422 — in the shared envelope {"error": {...}}.

POST /customers/{customer_id}/stage

Move the customer's deal to another stage

Requires 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.

ParameterInTypeDescription
customer_idrequired path uuid

Request body

FieldTypeDescription
stage_id uuid | null
stage_code string | null
reason string | null

Example

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

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

Errors: 422 — in the shared envelope {"error": {...}}.

GET /customers/{customer_id}/variables

Read customer variables

Requires 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.

ParameterInTypeDescription
customer_idrequired path uuid

Example

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

Response 200

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

Errors: 422 — in the shared envelope {"error": {...}}.

PATCH /customers/{customer_id}/variables

Write customer variables

Requires 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.

ParameterInTypeDescription
customer_idrequired path uuid

Request body

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

Example

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

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

Errors: 422 — in the shared envelope {"error": {...}}.

Deals

POST /deals/{deal_id}/close

Close a deal

Requires scope: customers:write

Marks the deal won or lost with an optional reason.

ParameterInTypeDescription
deal_idrequired path uuid

Request body

FieldTypeDescription
statusrequired string won | lost
reason string | null

Example

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

{
  "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"
}

Errors: 422 — in the shared envelope {"error": {...}}.

GET /deals/{deal_id}/variables

Read deal variables

Requires 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.

ParameterInTypeDescription
deal_idrequired path uuid

Example

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

Response 200

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

Errors: 422 — in the shared envelope {"error": {...}}.

PATCH /deals/{deal_id}/variables

Write deal variables

Requires 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.

ParameterInTypeDescription
deal_idrequired path uuid

Request body

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

Example

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

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

Errors: 422 — in the shared envelope {"error": {...}}.

Pipelines

GET /pipelines

List pipelines and their stages

Requires 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.

ParameterInTypeDescription
agent_id query uuid | null

Example

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

Response 200

[
  {
    "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
      }
    ]
  }
]

Errors: 422 — in the shared envelope {"error": {...}}.

promo-codes

GET /promo-codes/{code}

Look up who a code was issued to

Requires 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.

ParameterInTypeDescription
coderequired path string

Example

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

Response 200

{
  "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"
}

Errors: 422 — in the shared envelope {"error": {...}}.

promo-pools

GET /promo-pools

List promo-code pools

Requires 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.

ParameterInTypeDescription
agent_id query uuid | null Только пулы этого агента

Example

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

Response 200

{
  "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
    }
  ]
}

Errors: 422 — in the shared envelope {"error": {...}}.

GET /promo-pools/{pool_id}

Read one promo-code pool

Requires scope: promo:read

Totals, remaining codes, alert threshold and the keywords that hand out from this pool.

ParameterInTypeDescription
pool_idrequired path uuid

Example

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

Response 200

{
  "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"
}

Errors: 422 — in the shared envelope {"error": {...}}.

PATCH /promo-pools/{pool_id}

Update a pool (including stopping the giveaway)

Requires 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.

ParameterInTypeDescription
pool_idrequired path uuid

Request body

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

Example

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

{
  "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"
}

Errors: 422 — in the shared envelope {"error": {...}}.

GET /promo-pools/{pool_id}/codes

List the codes of a pool

Requires 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.

ParameterInTypeDescription
pool_idrequired path uuid
issued_only query boolean Только выданные
search query string | null Поиск по коду
limit query integer
offset query integer

Example

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

Response 200

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

Errors: 422 — in the shared envelope {"error": {...}}.

POST /promo-pools/{pool_id}/codes

Top up a pool with codes

Requires 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.

ParameterInTypeDescription
pool_idrequired path uuid

Request body

FieldTypeDescription
codesrequired string[] Коды, по одному в элементе списка

Example

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

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

Errors: 422 — in the shared envelope {"error": {...}}.

POST /promo-pools/{pool_id}/codes/revoke

Revoke codes

Requires 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.

ParameterInTypeDescription
pool_idrequired path uuid

Request body

FieldTypeDescription
codesrequired string[] Коды, которые больше не действуют

Example

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

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

Errors: 422 — in the shared envelope {"error": {...}}.

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.

ParameterInTypeDescription
agent_id query uuid | null

Example

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

Response 200

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

Errors: 422 — in the shared envelope {"error": {...}}.

POST /whatsapp/templates

Create a WhatsApp template

Requires scope: templates:manage

The call does not clear Meta's moderation: the template is queued as PENDING and later becomes APPROVED or REJECTED.

ParameterInTypeDescription
agent_id query uuid | null

Request body

FieldTypeDescription
namerequired string
categoryrequired string MARKETING | UTILITY | AUTHENTICATION
languagerequired string
componentsrequired object<any>[] Meta component array (header/body/footer/buttons)

Example

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

{
  "key": "string"
}

Errors: 422 — in the shared envelope {"error": {...}}.