Opero

API and MCP

Opero for developers

Connect Opero to Claude, ChatGPT, Cursor and your own software. The MCP server and the REST API work with one company's data: leads, counterparties, deals, tasks, notes, invoices, AI staff and the Today summary.

Updated October 20, 2026

On this page

Quick start

Keys, connected apps and webhooks are managed by the company owner or an admin in Opero: Settings, API & AI integrations.

MCP server
https://my.operobusiness.ru/api/mcp
REST API
https://my.operobusiness.ru/api/v1
Sign-in
OAuth 2.1 or an API key in the Authorization header
Format
JSON in UTF-8, ISO 8601 dates (UTC)

Create a read-only key and check it:

Check the key
curl https://my.operobusiness.ru/api/v1/me \
  -H "Authorization: Bearer $OPERO_API_KEY"

The response shows the company, the key's access and the person who created it.

MCP server

Address https://my.operobusiness.ru/api/mcp. Streamable HTTP transport, one JSON response per request. Supported protocol versions: 2026-07-28 (no handshake, the version in every request, server/discover available) and 2025-11-25, 2025-06-18, 2025-03-26 (through initialize). No sessions are used.

Sign-in: OAuth 2.1 (Claude, ChatGPT and other clients run it themselves) or an API key in the Authorization: Bearer opk_... header for Cursor, Claude Code and your own agents. Without sign-in the server answers 401 with a WWW-Authenticate header that points to the resource metadata.

Connecting

  • Claude: claude.ai, the Customize menu, Connectors, Add custom connector. Paste the server address, click Add, then Connect and allow access in Opero.
  • ChatGPT: chatgpt.com/plugins, the + button, Add custom MCP server. Paste the server address, keep OAuth sign-in and click Create as a plugin.
  • Cursor: Cursor Settings, Tools & MCP, or ~/.cursor/mcp.json with an API key from an environment variable:
~/.cursor/mcp.json
{
  "mcpServers": {
    "opero": {
      "url": "https://my.operobusiness.ru/api/mcp",
      "headers": { "Authorization": "Bearer ${env:OPERO_API_KEY}" }
    }
  }
}
Claude Code
claude mcp add --transport http opero https://my.operobusiness.ru/api/mcp

Tools

ToolWhat it doesAccess
searchsearch leads, counterparties, tasks and invoices; ids for fetchread
fetchfull record by an id from searchread
get_todaythe Today summary: numbers, approvals waiting, what the AI staff didread
list_leadsleads with filters and pagesread
get_leada lead card: deal stage, notes, open tasksread
create_leada new lead, optionally without duplicateswrite
update_leadchange lead fields and statuswrite
list_companiescounterparties (leads with an INN)read
get_companya counterparty by idread
create_companyadd a counterparty by INNwrite
list_dealsthe deal pipeline: stages, amounts, next stepread
get_deala deal with its stage historyread
move_dealmove a deal to another stagewrite
list_tasksteam tasksread
create_taska task for a person, optionally for a leadwrite
update_tasktask status, deadline, assigneewrite
list_notesnotes on a leadread
add_noteadd a notewrite
list_invoicesinvoices (read only)read
get_invoicean invoice with line items and a PDF linkread
list_ai_employeesAI staff: roles, autonomy, work todayread
list_memberscompany members (ids for tasks)read
ask_operoa question or instruction for Ask Operoread
get_ask_resultthe answer if it was not ready right awayread
Read-only keys and connections only see read tools. Deleting data, approving AI staff decisions, messaging clients, payments, ads and hiring are not available through MCP: people do that in Opero.

ask_opero sends the question to Ask Opero on behalf of the person who granted access: the question and the answer appear in their chat. From external apps Opero only prepares drafts (emails and invoices wait for approval in Opero), never hires, spends money or messages clients. If the answer was not ready in time, call get_ask_result with request_id.

Authorization

API keys

  • The owner or an admin creates a key: Settings, API & AI integrations, Create key. The key is shown once, Opero stores only its hash.
  • A key belongs to one company. Access: read, write (read and write) or channels (external chat only: support conversations, no company data, see External chat).
  • Send the key only in a header: Authorization: Bearer opk_... or X-Api-Key: opk_.... Keys in the URL are not accepted.
  • Revoking takes effect immediately. On every call Opero checks that the person who created the key is still in the company: if they were removed, the key stops working.

OAuth 2.1

For apps that connect users themselves: Claude, ChatGPT, your services. Issuer https://my.operobusiness.ru/api/oauth.

AddressPurpose
/api/oauth/protected-resourceMCP resource metadata (RFC 9728)
/api/oauth/.well-known/openid-configurationauthorization server metadata
/api/oauth/registerclient registration (RFC 7591)
/api/oauth/authorizesign-in and user consent
/api/oauth/tokencode exchange and token refresh
/api/oauth/revoketoken revocation (RFC 7009)
  • Authorization code with PKCE, S256 only.
  • Clients register themselves (none, client_secret_post or client_secret_basic) or use a client ID metadata document: client_id is the document's https address.
  • The resource parameter (RFC 8707): https://my.operobusiness.ru/api/mcp (default) or https://my.operobusiness.ru/api/v1. A token works only for its resource.
  • Scopes: read and write. On the consent page the person picks a company where they are the owner or an admin and can keep read-only access.
  • The authorization response includes iss (RFC 9207).
  • Access tokens live 1 hour, refresh tokens 60 days and rotate on every refresh. Reusing an old refresh token revokes the whole connection.
  • Redirect URIs: https, http://localhost and http://127.0.0.1 with any port, app schemes.

Connected apps are listed in API & AI integrations, where they are disconnected with one click.

REST API

Base address https://my.operobusiness.ru/api/v1. The company comes from the key, so it is not part of the paths.

A successful response is {"data": ...}. Lists return next_cursor: pass it as cursor to get the next page. Page size limit is 1 to 100, 25 by default. Errors: an HTTP status and {"error": {"code": "...", "message": "..."}}, the message language follows Accept-Language.

MethodPathWhat it doesAccess
GET/mecompany, key and its accessread
GET/leadsleads: status, q, updated_since, created_sinceread
POST/leadscreate a leadwrite
GET/leads/{id}lead, deal stage, notes, tasksread
PATCH/leads/{id}update a leadwrite
GET/leads/{id}/notesnotesread
POST/leads/{id}/notesadd a note {text}write
GET/companiescounterparties: qread
POST/companiescreate a counterparty {name, inn}write
GET/companies/{id}a counterpartyread
PATCH/companies/{id}update a counterpartywrite
GET/dealsdeals by stage: stageread
GET/deals/stageslist of stagesread
GET/deals/{lead_id}a deal with historyread
POST/deals/{lead_id}/stagemove {stage, reason}write
GET/taskstasks: status, assignee_id, lead_id, overdue, qread
POST/taskscreate a taskwrite
GET/tasks/{id}a taskread
PATCH/tasks/{id}update a taskwrite
GET/invoicesinvoices: status, lead_id, qread
GET/invoices/{id}an invoiceread
GET/ai-employeesAI staffread
GET/memberscompany membersread
GET/todaythe Today summaryread
GET/searchsearch: qread
POST/askask Opero {question, wait}read
GET/ask/{id}the answerread
Leads and counterparties are created through the same functions as Opero's screens: Bitrix24 sync and automation rules work as with manual changes. dedupe: true returns the existing lead with the same email, phone or INN instead of a duplicate.

Examples

A new lead without duplicates
curl https://my.operobusiness.ru/api/v1/leads \
  -H "Authorization: Bearer $OPERO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Codlab", "email": "hello@codlab.example", "source": "website", "dedupe": true}'
Deal won
curl -X PATCH https://my.operobusiness.ru/api/v1/leads/$LEAD_ID \
  -H "Authorization: Bearer $OPERO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status": "won", "amount": 48000}'
Move a deal to the proposal stage
curl https://my.operobusiness.ru/api/v1/deals/$LEAD_ID/stage \
  -H "Authorization: Bearer $OPERO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"stage": "proposal", "reason": "Proposal sent from the website"}'
A task for a person
curl https://my.operobusiness.ru/api/v1/tasks \
  -H "Authorization: Bearer $OPERO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"title": "Call Codlab", "lead_id": "'$LEAD_ID'", "deadline": "2026-10-25T10:00:00Z"}'
Ask Opero
curl https://my.operobusiness.ru/api/v1/ask \
  -H "Authorization: Bearer $OPERO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"question": "Which deals are closest to payment?", "wait": 30}'
All leads page by page (Node.js)
const base = "https://my.operobusiness.ru/api/v1"
const headers = { Authorization: `Bearer ${process.env.OPERO_API_KEY}` }

let cursor = ""
do {
  const url = `${base}/leads?limit=100${cursor ? `&cursor=${cursor}` : ""}`
  const res = await fetch(url, { headers })
  if (res.status === 429) {
    await new Promise((r) => setTimeout(r, Number(res.headers.get("Retry-After") || 5) * 1000))
    continue
  }
  const page = await res.json()
  for (const lead of page.data) console.log(lead.name, lead.status)
  cursor = page.next_cursor || ""
} while (cursor)

External chat

Support conversations from your app and website reach the Opero Inbox through your server. Your AI assistant answers first and hands the conversation over to people: your server creates a conversation in Opero, your team replies in the Inbox, and each reply goes straight to your server as a webhook.

URL
https://my.operobusiness.ru/api/v1/conversations
Key
an API key with External chat only access (channels): without it 403, company data is not visible with this key
Channel
Settings, External chat: channel key, webhook URL, signing secret, how AI replies, retention
Limit
1,200 requests per minute per company, no per-address limits; 429 with Retry-After. Typing signals are counted separately: 6,000 per minute

Personal data

  • You can send a pseudonym only: contact.label (Passenger 7K2Q), a permanent external client ID contact.external_id, the language ru, tg or en, the source app_ios, app_android or site, and texts with placeholders instead of data: [код брони] (booking code), [телефон] (phone), [почта] (email), [номер билета] (ticket number), [номер документа] (document number), [фамилия] (surname).
  • Or you can show the client's details to your team right in the conversation card: the customer and card blocks (see Client card below). Only your company's team sees the card; Opero stores it encrypted and never passes it to the AI model or to analytics. Opero AI never asks for or repeats personal data.
  • Opero downloads client files from your link as soon as they arrive and keeps them encrypted: your team sees photos as images and can zoom in, PDFs and other files are downloaded. The link must work without signing in for at least a few minutes, files up to 15 MB.

Endpoints

MethodPathWhat it does
POST/conversationscreate or reopen a conversation: 201 new, 200 existing
GET/conversations/{id}the conversation: status, owner, fields, client card
PUT/conversations/{id}/cardupdate the client card: 200, no new messages
POST/conversations/{id}/messagesa client message: 201, a repeated client_id 200
GET/conversations/{id}/messages?after=&limit=messages visible to the client in seq order, next field
POST/conversations/{id}/statusstatus: resolved or open, reason
POST/conversations/{id}/ratingscore 1 to 5, comment
POST/conversations/{id}/typingthe client is typing: {"state": "typing"}, 204
POST/conversations/{id}/readthe client has read replies up to seq: {"seq": N}, 204
DELETE/conversations/{id}delete completely: 204
GET/channelscompany channels: slug, name, AI mode

{id}: the Opero ID or your ID as ext:<external_id>. JSON in UTF-8, times in ISO 8601 (UTC), errors {"error": {"code", "message"}}, message language by Accept-Language.

Conversation

A conversation is unique by external_id within the company. A repeat with the same external_id returns the existing conversation (200) and adds history without duplicates by client_id; a resolved or closed conversation is reopened. topic: refund, booking, baggage, payment, complaint, other; priority: normal or high; links: up to 10 https links, shown in Opero as the Client details button; history: up to 50 messages {client_id, role, text, at}, role: customer, assistant or system, text up to 4,000 characters.

Hand a conversation over to people
curl https://my.operobusiness.ru/api/v1/conversations \
  -H "Authorization: Bearer $OPERO_CHANNELS_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "conv-1029",
    "channel": "support",
    "source": "app_ios",
    "contact": {"external_id": "c-58f2", "label": "Passenger 7K2Q", "lang": "en"},
    "topic": "booking",
    "priority": "normal",
    "summary": "The client asks where to find the boarding pass for [код брони].",
    "links": [{"title": "Client card", "url": "https://support.example.com/clients/c-58f2"}],
    "history": [
      {"client_id": "h-1", "role": "customer", "text": "Where is my boarding pass? Booking [код брони]", "at": "2026-10-20T09:40:00Z"},
      {"client_id": "h-2", "role": "assistant", "text": "I am handing your question over to our team.", "at": "2026-10-20T09:40:05Z"}
    ]
  }'
Response
{
  "id": "5b8e2c1a-7d4f-4a3e-9c2b-1e6f8a0d3c5b",
  "external_id": "conv-1029",
  "channel": "support",
  "status": "open",
  "url": "https://my.operobusiness.ru/inbox?thread=5b8e2c1a-7d4f-4a3e-9c2b-1e6f8a0d3c5b",
  "created": true,
  "reopened": false,
  "history_added": 2,
  "last_seq": 0,
  "contact_id": null,
  "card_updated_at": null
}

Client card

Client details arrive as the customer and card blocks in POST /conversations or in a separate PUT /conversations/{id}/card with the same blocks: a 200 answer, and no new messages appear in the conversation. A block you send replaces the previous one entirely; in PUT the value null removes a block, in POST you can leave out a block that hasn't changed. Responses carry contact_id (the CRM contact) and card_updated_at (when the card was updated). An invalid block in POST doesn't stop the handoff: the conversation is created, the block isn't saved, and the response carries warnings [{field, code, message}]; in PUT an invalid block gets 422 invalid_field.

  • customer: name up to 120 characters, phone like +992177590600 (plus, country code and number), email up to 254 characters, lang: ru, tg or en, platform: app_ios, app_android or site, club: {status, miles}, the loyalty program.
  • card: {"sections": [{"title", "rows": [{"label", "value", "link"}]}]}, up to 10 sections, up to 30 rows per section, value up to 500 characters, link https only: it opens in a new tab for your team. Sections are shown as they are, in the order they arrive.
  • Your team sees the card to the right of the conversation, on a phone behind a button above it: the name in large type, the phone with Copy and Call buttons, email, language, where the client writes from, the club, the sections and when the card was updated. After a PUT an open card refreshes on its own within a second or two.
  • By phone number the conversation is linked to a contact in your company's CRM (the last 10 digits are compared). If there is no such contact, Opero creates one with the name, phone and email. All of the client's conversations are visible on the contact page.
Update the card
curl -X PUT https://my.operobusiness.ru/api/v1/conversations/ext:conv-1029/card \
  -H "Authorization: Bearer $OPERO_CHANNELS_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "customer": {
      "name": "Dilnoza Karimova",
      "phone": "+992900000001",
      "email": "dilnoza@example.com",
      "lang": "en",
      "platform": "app_ios",
      "club": {"status": "Silver", "miles": 2450}
    },
    "card": {"sections": [
      {"title": "Bookings", "rows": [
        {"label": "DW4K7M", "value": "DW 040 Dushanbe - Khorog, 21.10 07:40, paid, seats 3A and 3B", "link": "https://support.example.com/bookings/DW4K7M"}
      ]},
      {"title": "Tickets", "rows": [
        {"label": "KARIMOVA DILNOZA", "value": "240 0000000002, seat 3A"}
      ]}
    ]}
  }'
Response
{
  "id": "5b8e2c1a-7d4f-4a3e-9c2b-1e6f8a0d3c5b",
  "external_id": "conv-1029",
  "changed": true,
  "customer": {"name": "Dilnoza Karimova", "phone": "+992900000001", "email": "dilnoza@example.com", "lang": "en", "platform": "app_ios", "club": {"status": "Silver", "miles": 2450}},
  "card": {"sections": [{"title": "Bookings", "rows": [{"label": "DW4K7M", "value": "DW 040 Dushanbe - Khorog, 21.10 07:40, paid, seats 3A and 3B", "link": "https://support.example.com/bookings/DW4K7M"}]}, {"title": "Tickets", "rows": [{"label": "KARIMOVA DILNOZA", "value": "240 0000000002, seat 3A"}]}]},
  "card_updated_at": "2026-10-21T08:12:40.512Z",
  "contact_id": "8f1d2c3b-4a5e-4f60-9b7c-2d3e4f5a6b7c"
}

Client messages

Text up to 4,000 characters, up to 5 file links {name, mime, size, url}. Opero downloads the files right away (https and public addresses only, up to 3 redirects, up to 15 MB, the type is detected from the content), retries after 30 seconds, 2 and 10 minutes and keeps a copy: your team can open it even after your link has stopped working. In GET /messages client files come with your own links. A repeat with the same client_id returns the stored message (200) and ignores the new body. A closed conversation accepts no messages: 409 conversation_closed; reopen it with POST /conversations and the same external_id.

A message with a file
curl https://my.operobusiness.ru/api/v1/conversations/ext:conv-1029/messages \
  -H "Authorization: Bearer $OPERO_CHANNELS_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "client_id": "m-3",
    "text": "Here is a screenshot",
    "attachments": [{"name": "screen.png", "mime": "image/png", "size": 48211, "url": "https://files.example.com/s/9f2c"}],
    "at": "2026-10-20T09:41:10Z"
  }'

Conversation messages

GET /conversations/{id}/messages?after=&limit= returns the messages the client sees (their own messages and the replies sent by your team and AI; drafts and internal notes are never included) in ascending seq order. Response: {"messages": [...], "next": <seq or null>, "last_seq": <number>}.

  • after: return messages with a seq greater than this number (0 by default), limit: 1 to 100, 50 by default.
  • next: the seq of the last message on the page when there are more than limit messages (pass it as after in the next request), otherwise null. last_seq: the latest number in the conversation.
  • A message: id, seq, direction (in from the client, out from your team or AI), text, author, attachments [{name, mime, size, url}], created_at; client messages also carry your client_id.
  • author: {"type": "customer"} for the client, {"type": "agent" | "ai", "id", "name"} for replies: a reply a team member approved from an AI draft comes as a regular team reply (agent). Show the client every message with type agent or ai. Files from your team come as links valid for 24 hours.
Reconcile after webhook failures
curl "https://my.operobusiness.ru/api/v1/conversations/ext:conv-1029/messages?after=1&limit=50" \
  -H "Authorization: Bearer $OPERO_CHANNELS_KEY"

{
  "messages": [
    {
      "id": "m_5d2c7a9e1b3f4c6a8e0d2b4f",
      "seq": 2,
      "direction": "in",
      "text": "Here is a screenshot",
      "author": {"type": "customer"},
      "attachments": [{"name": "screen.png", "mime": "image/png", "size": 48211, "url": "https://files.example.com/s/9f2c"}],
      "created_at": "2026-10-20T09:41:10.380Z",
      "client_id": "m-3"
    },
    {
      "id": "m_9a1c3e5f7b2d4a6c8e0f1a3b",
      "seq": 3,
      "direction": "out",
      "text": "Hello. The boarding pass is in the app, under My trips.",
      "author": {"type": "agent", "id": "0f3e5d7c-9b1a-4c2e-8d6f-a1b3c5d7e9f0", "name": "Anna P."},
      "attachments": [],
      "created_at": "2026-10-20T09:45:00.120Z"
    }
  ],
  "next": null,
  "last_seq": 3
}

The client is typing and has read

  • POST /conversations/{id}/typing with the body {"state": "typing"}: a 204 answer without a body. Your team sees a line with the client pseudonym in the conversation window, for example "Passenger 7K2Q is typing…", for 6 seconds: a new signal extends it and a client message clears it. Send the signal every 1 to 3 seconds while the client is typing.
  • The signal is neither stored nor retried. Your team gets at most one signal per second per conversation, extra ones also get 204. It has its own limit, 6,000 per minute per company, and doesn't use the general limit of 1,200. A closed conversation gets no signal (204), any other state value: 422 invalid_field.
  • POST /conversations/{id}/read with the body {"seq": N}: a 204 answer. The client has read your team's messages up to and including number N (numbers from conversation.message webhooks and GET /messages); those replies get a read mark in your team's window.
  • The number only grows: a smaller or equal one changes nothing, a number above the latest counts as the latest. The mark is stored with the conversation and deleted with it.
The client is typing
curl https://my.operobusiness.ru/api/v1/conversations/ext:conv-1029/typing \
  -H "Authorization: Bearer $OPERO_CHANNELS_KEY" \
  -H "Content-Type: application/json" \
  -d '{"state": "typing"}'

HTTP/1.1 204 No Content
The client has read up to seq 3
curl https://my.operobusiness.ru/api/v1/conversations/ext:conv-1029/read \
  -H "Authorization: Bearer $OPERO_CHANNELS_KEY" \
  -H "Content-Type: application/json" \
  -d '{"seq": 3}'

HTTP/1.1 204 No Content

Webhooks

Each event is sent right away, not in batches: a POST with JSON to the channel URL. Headers: Content-Type: application/json, Opero-Event-Id, Opero-Event, Opero-Signature, Opero-Attempt.

EventWhen it arrivesFields
conversation.messagea team member or AI sent a message to the clientmessage: id, seq, text, author {type agent|ai, id, name}, attachments, created_at
conversation.statusthe status changed: open, pending (waiting for the client), resolved, closedstatus, previous, by
conversation.assigneda team member took the conversation or handed it overassignee, previous, by
conversation.typinga team member is typing, at most once every 3 seconds, no retriesauthor

Unapproved AI drafts and internal marks are never sent. A team member's name arrives as the first name and the surname initial; AI replies carry the name from the channel settings. A reply approved from an AI draft comes as a regular team reply. Every message has a seq that grows within the conversation.

Every event has the same body: id (the same as Opero-Event-Id), type (the same as Opero-Event), created_at (ISO 8601 UTC), channel (the channel key), conversation: {"id", "external_id"}, and the event's own fields next to them at the top level: message, status, assignee, author. There is no separate event field: the event type is in type.

  • conversation.status: status and previous from open, pending, resolved, closed; by.type: agent (a team member, with id and name), ai, client (your server through the API), customer (a new client message reopened the conversation), system.
  • conversation.assigned: assignee and previous: {"id", "name"} or null (unassigned); by: {"type": "agent", "id", "name"} or null when the conversation was assigned automatically in turn.
  • conversation.typing: author: {"type": "agent", "id", "name"}.
  • ping (the Test address button in the channel settings): no conversation, a message field, never retried.
conversation.message
{
  "id": "evt_3f1c9a7e5b2d4c6a8e0f1a3b5c7d9e1f",
  "type": "conversation.message",
  "created_at": "2026-10-20T09:45:00.120Z",
  "channel": "support",
  "conversation": {"id": "5b8e2c1a-7d4f-4a3e-9c2b-1e6f8a0d3c5b", "external_id": "conv-1029"},
  "message": {
    "id": "m_9a1c3e5f7b2d4a6c8e0f1a3b",
    "seq": 3,
    "text": "Hello. The boarding pass is in the app, under My trips.",
    "author": {"type": "agent", "id": "0f3e5d7c-9b1a-4c2e-8d6f-a1b3c5d7e9f0", "name": "Anna P."},
    "attachments": [],
    "created_at": "2026-10-20T09:45:00.120Z"
  }
}
conversation.status
{
  "id": "evt_7a2b4c6d8e0f1a3b5c7d9e1f2a4b6c8d",
  "type": "conversation.status",
  "created_at": "2026-10-20T09:45:00.480Z",
  "channel": "support",
  "conversation": {"id": "5b8e2c1a-7d4f-4a3e-9c2b-1e6f8a0d3c5b", "external_id": "conv-1029"},
  "status": "pending",
  "previous": "open",
  "by": {"type": "agent", "id": "0f3e5d7c-9b1a-4c2e-8d6f-a1b3c5d7e9f0", "name": "Anna P."}
}
conversation.assigned
{
  "id": "evt_1b3d5f7a9c2e4b6d8f0a2c4e6b8d0f1a",
  "type": "conversation.assigned",
  "created_at": "2026-10-20T09:45:00.510Z",
  "channel": "support",
  "conversation": {"id": "5b8e2c1a-7d4f-4a3e-9c2b-1e6f8a0d3c5b", "external_id": "conv-1029"},
  "assignee": {"id": "0f3e5d7c-9b1a-4c2e-8d6f-a1b3c5d7e9f0", "name": "Anna P."},
  "previous": null,
  "by": {"type": "agent", "id": "0f3e5d7c-9b1a-4c2e-8d6f-a1b3c5d7e9f0", "name": "Anna P."}
}
conversation.typing
{
  "id": "evt_8c0e2a4b6d8f1a3c5e7b9d1f3a5c7e9b",
  "type": "conversation.typing",
  "created_at": "2026-10-20T09:44:52.031Z",
  "channel": "support",
  "conversation": {"id": "5b8e2c1a-7d4f-4a3e-9c2b-1e6f8a0d3c5b", "external_id": "conv-1029"},
  "author": {"type": "agent", "id": "0f3e5d7c-9b1a-4c2e-8d6f-a1b3c5d7e9f0", "name": "Anna P."}
}
ping
{
  "id": "evt_4e6a8c0b2d4f6a8c0e2b4d6f8a0c2e4b",
  "type": "ping",
  "created_at": "2026-10-20T09:30:00.002Z",
  "channel": "support",
  "message": "Opero webhook test"
}

Signature

Opero-Signature: t=<time>,v1=<signature>, where the signature is the hex HMAC-SHA256 of the string <time>.<body> with the channel secret; use the body bytes exactly as received. The secret is shown once, when the channel is created and when it is reissued. For 24 hours after a reissue the header carries two v1 values, one per secret: accept the request if either matches.

Secret
whsec_example_only_7Kq2Vd9Lm4Xp
Time (t)
1792489500
Body
{"id":"evt_1","type":"ping"}
Header
t=1792489500,v1=e3da66a36e54afa70864c58ad343c4d379aa478000a0aa3c46c0ea4e8f608137
Verify the signature (Node.js)
import crypto from "node:crypto"

// rawBody: the raw request body (Buffer) exactly as received, before JSON.parse.
// secrets: [current] or, for 24 hours after a reissue, [new, old].
export function verifyOpero(rawBody, header, secrets) {
  const items = header.split(",").map((p) => p.trim().split("="))
  const t = items.find(([k]) => k === "t")?.[1]
  const sigs = items.filter(([k]) => k === "v1").map(([, v]) => Buffer.from(v))
  if (!t || !sigs.length || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false
  return secrets.some((secret) => {
    const expected = Buffer.from(crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex"))
    return sigs.some((s) => s.length === expected.length && crypto.timingSafeEqual(s, expected))
  })
}
Verify the signature (Python)
import hashlib, hmac, time

def verify_opero(raw_body: bytes, header: str, secrets: list[str]) -> bool:
    items = [p.strip().split("=", 1) for p in header.split(",")]
    t = next((v for k, v in items if k == "t"), None)
    sigs = [v for k, v in items if k == "v1"]
    if not t or not sigs or abs(time.time() - int(t)) > 300:
        return False
    for secret in secrets:
        expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
        if any(hmac.compare_digest(expected, s) for s in sigs):
            return True
    return False

Delivery and retries

  • A 2xx answer within 10 seconds means delivered. Otherwise retries after 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours and 24 hours with the same Opero-Event-Id: store it and don't process an event twice.
  • A 410 answer stops the retries of that event.
  • Events of one conversation are sent one at a time in seq order. A failed one doesn't hold the next ones: if you see a gap in seq, call GET /conversations/{id}/messages?after=<last seq>.
  • https and public addresses only: the address is checked before every request, Opero never calls private or local addresses and doesn't follow redirects.

Statuses and AI

  • A new client message makes the conversation open (open); a reply from your team or AI makes it waiting for the client (pending). Your team can set resolved and closed, your server resolved and open.
  • AI mode per channel: drafts (a team member approves or edits, the client only sees approved replies), replies on its own (a message with author.type: "ai") or off.
  • Money, refunds, complaints and claims, ticket errors, baggage, health and special cases, threats and requests for a person always go to your team without an AI reply, and the conversation is marked as needing a person.
  • AI answers in the client's language from the company knowledge base, never promises refunds, deadlines or amounts, and doesn't quote prices, schedules or booking statuses: it sends the client to your assistant in the app and on the website.

Retention and deletion

  • Conversations are kept for the number of days set in the channel (365 by default) and then deleted completely, together with the client card and files.
  • DELETE /conversations/{id} deletes the whole conversation with its AI and analytics copies: messages, history, the client card, client and team files, translations for your team, AI employee log entries and webhook deliveries. A CRM contact Opero created for this conversation is deleted as well if it has no other conversations or records from your team.
  • Message texts, card details and file links are not written to server logs: only IDs, sizes and codes.

Errors

HTTPcodeWhen
400invalid_jsonthe body is not a JSON object
401unauthorizedno key or an invalid key
403insufficient_scopethe key has no channels access
404not_foundthe conversation doesn't exist or was deleted
409conversation_closeda message to a closed conversation
409channel_mismatchthe external_id belongs to a conversation in another channel
422invalid_field, channel_not_founda field failed validation (including state, seq and the customer and card fields): its name is in field, for example card.sections[0].rows[2].value
429rate_limitedover 1,200 requests per minute per company (over 6,000 typing signals), wait for Retry-After

Webhooks

Opero sends a POST with JSON to your https address when something happens in the company. The address and events are set in API & AI integrations, along with the delivery log and a test button.

EventWhen it is sentWhat is in data
lead.createda new lead appeared: from a form, a channel, manually or through the APIlead
deal.wona lead got the won status, including when the pipeline sees the paymentlead, amount
invoice.paidan invoice was marked paid: by the bank, 1C or manuallyinvoice
Request body
{
  "id": "evt_5c0e9b1f2a7d4f6e8b3c1d2e4f6a8b0c",
  "type": "lead.created",
  "created_at": "2026-10-20T09:15:02Z",
  "company_id": "c0a8012e-5b7d-4e1f-9a3b-2d4e6f8a0b1c",
  "data": {
    "lead": {
      "id": "7d9e1f3a-2b4c-4d6e-8f0a-1b3c5d7e9f2a",
      "name": "Codlab",
      "status": "new",
      "amount": null,
      "phone": "+7 900 000-00-00",
      "email": "hello@codlab.example",
      "source": "website",
      "url": "https://my.operobusiness.ru/leads/?lead=7d9e1f3a-2b4c-4d6e-8f0a-1b3c5d7e9f2a",
      "created_at": "2026-10-20T09:14:51Z"
    }
  }
}

Headers

  • Opero-Event: the event type, Opero-Delivery: the delivery id, Opero-Timestamp: send time (Unix seconds).
  • Opero-Signature: t=<time>,v1=<signature>, where the signature is HMAC-SHA256 with the webhook secret over <time>.<request body>, hex encoded.
  • User-Agent: Opero-Webhooks/1.0.

Verify the signature against the raw request body, compare in constant time and reject requests older than 5 minutes. The secret is shown once when the webhook is created.

Verify the signature (Node.js)
import crypto from "node:crypto"

// rawBody: the raw request body (Buffer), before JSON.parse
export function verifyOpero(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")))
  const age = Math.abs(Date.now() / 1000 - Number(parts.t))
  if (!parts.t || !parts.v1 || age > 300) return false
  const expected = Buffer.from(
    crypto.createHmac("sha256", secret).update(`${parts.t}.`).update(rawBody).digest("hex"),
  )
  const got = Buffer.from(parts.v1)
  return got.length === expected.length && crypto.timingSafeEqual(got, expected)
}
Verify the signature (Python)
import hashlib, hmac, time

def verify_opero(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t, v1 = parts.get("t"), parts.get("v1")
    if not t or not v1 or abs(time.time() - int(t)) > 300:
        return False
    expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)

Delivery and retries

  • A 2xx response within 10 seconds means delivered.
  • Otherwise Opero retries after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours: 6 attempts in total.
  • Redirects are not followed. The address must be https and public: Opero never calls private or local addresses.
  • A 410 response or 20 failed events in a row turn the webhook off, you can turn it back on in settings.
  • Events are checked every 30 seconds, so they arrive within about a minute. The same event may arrive twice: store the event id and skip repeats.
The Test button in settings sends a ping event and shows your server's answer.

Limits

WhatLimit
Requests per key or connected app120 per minute and 3,000 per hour
All requests of a company600 per minute
Writes60 per minute per key or app
Ask Opero (ask)10 per hour per key, 30 per hour per company
API keys20 per company
Connected apps50 per company
Webhooks10 per company
Request body256 KB, 1 MB for MCP

Over the limit the answer is HTTP 429 with a Retry-After header in seconds. In MCP a tool call over the limit returns a result with isError so the model waits.

Error codes

CodeHTTPMeaning
unauthorized401no key or token in the header
invalid_token401the key or token is invalid, revoked or expired
wrong_audience401the OAuth token was issued for another resource
owner_inactive401the person who granted access is no longer in the company
insufficient_scope403write access is required
read_only_company403the company is read-only
bad_request400invalid fields, the response includes field
assignee_is_ai400tasks for AI employees are assigned inside Opero
not_found404the record does not exist or belongs to another company
rate_limited429the limit is exceeded, see Retry-After
unavailable503a temporary failure, retry later

Security

  • Every request sees only the company of its key or connection: other records answer 404.
  • Access is never wider than that of the person who granted it, and it is checked on every call.
  • Keys and tokens are stored in Opero only as hashes. The webhook secret is stored separately because it is needed to sign requests.
  • Every call goes to the API & AI integrations log for 7 days.
  • Connections are encrypted, backups are made every night, and every AI action is in the log.
  • Data may contain text written by outside people (client messages, notes). If your agent reads it, it should treat such text as data, not as instructions.