1. Flowlu
  2. Flowlu Help Center
  3. Marketplace App Development Guide
  4. Building a Communications Module App

Building a Communications Module App


Documentation for developers implementing their own application that connects to Flowlu as a communication channel (MiniApp) in the Communications module.

1. Overview

What is a MiniApp channel

A MiniApp is a type of channel in the Flowlu Contact Center that allows your app (messenger, chat widget, corporate portal, etc.) to exchange messages with Flowlu managers.

For a MiniApp, you implement both sides of the communication yourself:

  • Inbound Channel — your server sends HTTP requests to Flowlu when a user writes/edits/deletes a message.
  • Outbound Channel — Flowlu sends HTTP requests to your server when a manager replies or initiates a chat.

What needs to be done

  1. Create an external app in Flowlu and describe the manifest with the "Communication service connection" integration point.
  2. Implement a connection page — it opens in an iframe inside Flowlu when a manager configures the integration.
  3. Implement an outbound webhook receiver on your server to handle requests from Flowlu.
  4. Connect to the Flowlu API to create/update the channel, send inbound webhooks, and report errors.

Hook specification (OpenAPI 3.1)

This document describes the integration as a whole. Full machine-readable schemas for hook payloads are attached to the article as two files:

  • incoming_events.yaml — inbound events (from your app to Flowlu).
  • outgoing_hooks.yaml — outbound hooks (from Flowlu to your app).

You can open the YAML files in a readable format using the online ReDoc service — https://redocly.github.io/redoc/ (upload the downloaded file into the form on the page).

2. Architecture and integration principles

There are three interaction channels between the parties:

ChannelInitiated ByPurpose
Connection iframe pageFlowlu opens it in an iframe, performs a POSTThe user configures the integration in Flowlu; your app receives the user context (including auth.access_token)
Flowlu APIYour serverCreating/updating/deleting a channel, sending inbound webhooks, reporting errors
Outbound WebhooksFlowluDelivering manager messages, initiating chats, channel lifecycle events

All API/webhook messages transmit JSON in the following format:

{ "method": "<method_name>", "payload": { /* fields */ } }

Glossary

TermDescription
External AppYour code — server-side + connection iframe page
ManifestYour application's configuration in Flowlu: integration points, access rights, URLs
Integration Point (placement)A point in the Flowlu UI where your page opens. For the Contact Center, this is contactcenter.service.wizard ("Communication service connection")
app.idThe UUID of your application in Flowlu. Sent in the POST request to the iframe
account.idNumerical ID of the Flowlu account where the channel is being connected. Sent in the POST request to the iframe
domainThe Flowlu account domain, e.g., my.flowlu.com. Sent in the POST request to the iframe; used as the base URL for API requests
auth.access_tokenOAuth2 token of the user who opened the iframe. Use it to call the API on behalf of this user
Communication Channel (bot)A specific connected integration: your bot_token. Created via the API
bot_id (CRM-side channel UUID)The UUID of the channel, returned from /contactcenter/bot/create. Used in inbound webhook URLs
bot_tokenIntegration
identifier on your side, e.g., the channel ID in your system. You set
this during channel creation, and Flowlu returns it in every outbound
hook as channel_id
event_idIdentifier of the outbound webhook event. Needed to send an error response if the request couldn't be processed; can also be used for deduplicating events from the CRM
external_chat_id / external_message_id / external_user_idIdentifiers for the chat / message / user in your system
inner_message_idIdentifier of the message in Flowlu. Sent in outbound message.new.personal, must be returned in message.completed.personal

3. Quick start

The complete path from zero to a working integration:

  1. Register the app in Flowlu (page /module/miniapps/cabinet), add the integration point contactcenter.service.wizard with the URL of your connection page. Grant the app access to the "Communications" module.
  2. Implement the connection iframe page. Flowlu performs a POST to this page with account data and a user OAuth2 token. Connect the Flowlu JS SDK for convenient iframe operation.
  3. Implement an outbound webhook receiver on your server at a persistent URL (you will specify this URL when creating the channel).
  4. Create a communication channel via POST /api/v1/module/contactcenter/bot/create on behalf of the user who opened the iframe.
  5. Exchange messages:
    • When a user types — you send message.new.personal to /external/rest/contactcenter/bot/hook_miniapp/{account_id}/{bot_id}.
    • When a manager types — Flowlu performs a POST to your webhook_url; you deliver it and confirm via message.completed.personal.

4. Creating an app in Flowlu

4.1 Registration in the cabinet

  1. Go to https://{your-domain}/module/miniapps/cabinet.
  2. Click Create App, fill in the main fields, and save.
  3. Open the created application and go to the manifest editing section.

4.2 Adding an integration point

Add the "Communication service connection" integration point (contactcenter.service.wizard).

Integration point fields:

FieldDescription
idArbitrary identifier of the integration point within your manifest
HeaderThe name the manager will see in the list of possible channels in the Contact Center
Icon (src)Public HTTPS link to the channel icon
ActionHow the connection page will open: modal window, side panel, or new tab
TitleTitle of the opened page (if modal window or side panel is selected)
SizeSize of the modal window / side panel
iframe (src)URL of your connection page. Flowlu will open it within its interface

4.3 Access rights

In the manifest settings, grant the application access to the APIs of the modules it will access:

  • Communications — mandatory; otherwise, you won't be able to create/update a channel via the API.
  • Users/Employees — if your application stores user data and will request it via /api/v1/module/core/user/get.

Without granted access, the API will return an authorization error.

After saving the manifest, on the page /_module/contactcenter/view/index?_ref=menu_app&tab=connected_bots, your integration will appear in the list of possible channels when clicking the "Connect" button. Upon clicking, Flowlu will open your iframe (src).

5. Integration point: connection iframe page

When a manager clicks on your integration item in the channel list, Flowlu opens the URL you specified in iframe (src) in an iframe and sends a POST request with the context to it.

5.1 What is sent in the POST

{
  "domain": "my.flowlu.com",
  "language": "en",
  "placement": {
    "id": "123abc",
    "code": "contactcenter.service.wizard"
  },
  "https": "1",
  "account": {
    "id": "123456"
  },
  "app": {
    "id": "e9277716-2840-11f1-9ce9-4a62868fa1db",
    "version": "1.0.0"
  },
  "auth": {
    "expires_at": "2026-03-27 17:29:33",
    "access_token": "...",
    "refresh_token": "..."
  },
  "ratelimit": {
    "limit": "16"
  },
  "bot_id": ""
}
FieldDescription
domainFlowlu account domain. Use as base URL: https://{domain}/api/v1/...
languageUser interface language
placement.idIntegration point ID from your manifest
placement.codeIntegration point code. For the Contact Center — always contactcenter.service.wizard
account.idNumerical ID of the Flowlu account. Used in inbound webhook URLs
app.idYour application's UUID
app.versionCurrent manifest version (relevant for public applications)
auth.access_tokenOAuth2 token of the user who opened the iframe
auth.refresh_tokenUser's refresh token
auth.expires_atExpiration time of the access_token
ratelimit.limitAPI request limit
bot_idUUID of an existing channel. If present — it's an edit page. If not — it's a creation page for a new channel

5.2 Token freshness and user data

The auth.* data is always up-to-date at the moment the iframe is opened — Flowlu automatically checks and updates tokens before rendering the page.

If your application stores user data on its side, perform a request to /api/v1/module/core/user/get after receiving the POST and update your copy:

GET https://{domain}/api/v1/module/core/user/get
Authorization: Bearer {auth.access_token}

For this endpoint to work, the corresponding access must be granted in the manifest.

5.3 Flowlu JS SDK

To work from the iframe (calling native dialogs, toasts, closing the frame, reading context), connect the official JS SDK.

The SDK provides convenient access to domain, app.id, the current user, and Flowlu UI primitives.

6. Authentication

6.1 Requests from your app to Flowlu

API (channel creation/update, user retrieval):

Header Authorization: Bearer {auth.access_token}. The token is user-specific, issued when the iframe opens. The request is executed on behalf of this user.

Inbound Webhooks (/external/rest/contactcenter/bot/hook_miniapp/{account_id}/{bot_id}):

Authentication is via the secret bot_id (CRM-side channel UUID) in the URL. The bot_id is server-generated, immutable, and is a secret — store it like an API key. account_id is the ID of your Flowlu portal, which comes in the POST request to the iframe.

6.2 Requests from Flowlu to your app

Outbound webhooks arrive at your webhook_url without a signature and without a Bearer token. The URL itself serves as proof of authenticity: if it is unpredictable, a leak is unlikely.

Recommendations:

  • Additionally verify that the channel_id arriving in the payload matches your integration's bot_token.
  • Use HTTPS on your side.

7. Creating a communication channel via API

7.1 Endpoint

POST https://{domain}/api/v1/module/contactcenter/bot/create
Authorization: Bearer {auth.access_token}
Content-Type: application/json

Where {domain} is the domain from the POST request to the iframe.

7.2 Payload

{
  "name": "<channel name set by the manager>",
  "webhook_url": "https:///webhook/flowlu",
  "bot_token": "<your integration identifier>",
  "active": 1,
  "can_write_first": true
}
FieldTypeRequiredDescription
namestringyesChannel name displayed to the manager in the Contact Center. Usually set by the manager on the connection page
webhook_urlstringyesHTTPS URL of your server for receiving outbound webhooks
bot_tokenstringyesIntegration identifier on your side. Returned in every outbound webhook as channel_id — used so you can identify which channel the event belongs to, especially if you have multiple channels on one webhook_url
activeint / boolno1 — active (default), 0 — disabled. A deactivated channel does not accept inbound hooks (responds with 409)
can_write_firstbooleannotrue — the "Write First" button is available to the manager. Default is false

7.3 Response

{
  "data": {
    "id": 123,
    "uuid": "550e8400-e29b-41d4-a716-446655440000",
    "name": "My Channel",
    "webhook_url": "https:///webhook/flowlu",
    "bot_token": "...",
    "active": true
  }
}
  • uuid — this is the bot_id you use in inbound webhook URLs. This is a secret.
  • id — internal numerical ID of the channel for subsequent updates/deletion.

Save uuid and id in your application's database, linking them to your internal integration.

7.4 Editing

If a bot_id arrived in the POST request to the iframe — it's an edit page. Find the channel by bot_id and update its fields via the API update method (name/webhook_url/active/can_write_first).

7.5 Deleting a channel

A channel can be deleted via the API. If the channel had a webhook_url set when deleted, Flowlu will send you a bot.deleted outbound hook — this is a good time to clear related data on your side.

8. Inbound webhooks (your app → Flowlu)

Full payload schemas for all methods in this section are in the incoming_events.yaml file attached to the article (OpenAPI 3.1).

8.1 Endpoint and general structure

POST https://{domain}/external/rest/contactcenter/bot/hook_miniapp/{account_id}/{bot_id}
Content-Type: application/json

Where:

  • {domain} — account domain (you saved it during channel creation);
  • {account_id} — account.id from the POST request to the iframe;
  • {bot_id} — channel uuid returned by /contactcenter/bot/create.

Body: { "method": "<...>", "payload": { ... } }.

8.2 Response codes

HTTPBodyMeaning
200{"success": true}Event accepted
400{"success": false, "message": ""}Invalid payload
404{"success": false, "message": "Not found"}Message not found or belongs to another channel
409{"success": false, "error": "...", "message": "..."}Channel deactivated or account blocked
410emptyChannel or account deleted
500{"success": false, "message": "Internal error"}Temporary Flowlu error

Note: 409 and 410 serve as an alternative path for detecting deactivation/deletion: if for some reason you didn't receive bot.deactivated/bot.deleted, these codes act as a safeguard.

8.3 message.new.personal — new message from external channel

When to call: a user in your application message to the chat, or you need to record a manager's message sent outside of Flowlu (echo).

{
  "method": "message.new.personal",
  "payload": {
    "text": "Hello, regarding order #42...",
    "send_date": 1710752400,
    "external_message_id": "msg_123",
    "external_user_id":    "user_456",
    "external_chat_id":    "chat_789",
    "direction": 0,
    "attachments": [],
    "user_data": {
      "name":        "John Doe",
      "username":    "john",
      "phone":       "+1234567890",
      "email":       "[email protected]",
      "avatar_url":  "https://example.com/avatar.jpg",
      "public_link": "https://example.com/john"
    }
  }
}
FieldTypeRequiredDescription
textstringno (default "")Message text
send_dateintno (default time())Unix timestamp (seconds)
external_message_idstringyesMessage ID in your system. Used for deduplication
external_user_idstringyesID of the interlocutor user in your system
external_chat_idstringyesChat ID in your system
directionintno0 — inbound (client → manager), default value. 1 — outbound (echo of a manager message sent outside Flowlu)
attachmentsarraynoList of attachments
user_dataobjectnoInterlocutor data

user_data contains the data of the interlocutor, not the sender. In outbound messages (direction = 1), this is still the client (the recipient), not the manager.

external_user_id is the interlocutor's ID, not the sender's. For outbound echo messages, this is still the client's ID, not the manager's ID.

Idempotency: messages are deduplicated by external_message_id within a single thread. A repeated request with the same ID is a no-op (200 OK, no side effects).

8.4 message.edit.personal — editing a message

{
  "method": "message.edit.personal",
  "payload": {
    "external_message_id": "msg_123",
    "edited_date": 1710752400,
    "new_text": "Updated text"
  }
}
FieldTypeRequiredDescription
external_message_idstringyesID of the message being edited
edited_dateintno (default time())Unix timestamp of editing
new_textstringno (default "")New text

If the message is not found or belongs to another channel — 404.

8.5 message.delete.personal — deleting a message

{
  "method": "message.delete.personal",
  "payload": {
    "external_message_id": "msg_123",
    "deleted_date": 1710752400
  }
}
FieldTypeRequiredDescription
external_message_idstringyesID of the message being deleted
deleted_dateintnoUnix timestamp of deletion

Deletion is soft — the message is hidden from the manager. You can only delete messages from your own channel; attempting to delete someone else's will return 404.

8.6 message.completed.personal — delivery confirmation

Sent after a message from the outbound hook message.new.personal is actually delivered to your channel and assigned an external_message_id.

Without this event, the manager's message remains in Flowlu with the status "sending".

{
  "method": "message.completed.personal",
  "payload": {
    "inner_message_id":    9001,
    "external_message_id": "ext_msg_555"
  }
}
FieldTypeRequiredDescription
inner_message_idintyesMessage ID in Flowlu (sent in outbound message.new.personal)
external_message_idstringyesMessage ID in your system

8.7 error — failed to process outbound hook

Sent if your application could not process an outbound webhook message.new.personal or chat.init.personal (e.g., chat closed, user blocked the bot, recipient does not exist in the external channel).

{
  "method": "error",
  "payload": {
    "event_id": "<event_id value from the outbound hook>",
    "message": "User not found"
  }
}
FieldTypeRequiredDescription
event_idstringyesThe event_id value from the original outbound webhook — returned exactly as received
messagestringnoFree text for logs

event_id is an opaque token. Flowlu uses it to understand exactly which event the error relates to and correctly update the status of the message/chat. Do not parse or modify it.

Effect:

  • If event_id corresponds to outbound message.new.personal — the manager's message is switched to "undelivered" status (error icon in UI).
  • If it corresponds to chat.init.personal — the manager receives a notification about a failed chat initiation.

event_id has a limited lifespan on the Flowlu side — send the error as soon as you identify the problem. You do not need to store the event_id beyond processing the current request.

8.8 message.read.personal (reserved)

The method is accepted by the server but not processed in the current version — it is a no-op. Do not build business logic on read-receipts: the read status is not saved anywhere and is not displayed to the manager.

9. Outbound webhooks (Flowlu → your app)

Full payload schemas for all hooks in this section are in the outgoing_hooks.yaml file attached to the article (OpenAPI 3.1).

9.1 Principle: hook = trigger, delivery is asynchronous

An outbound webhook is a trigger signal: "the manager types, send it to the external channel, "the manager initiates a chat, open it on your side," etc. Flowlu does not wait for the actual message delivery result in the HTTP response.

The processing flow looks like this:

  1. Flowlu performs a POST to your webhook_url.
  2. You respond with 2xx as soon as you accept the request for processing. The response means only "accepted."
  3. Next, you deliver the message to the user on your side (this can take any amount of time).
  4. Finally, you send a corresponding inbound webhook to Flowlu:
    • message.completed.personal — if delivery is successful;
    • error — if processing failed.

Without the counter-hook, the manager's message in Flowlu remains in "sending" status (for message.new.personal) or won't turn into a real thread (for chat.init.personal).

9.2 General request parameters

  • Endpoint: Your webhook_url specified during channel creation.
  • Method: POST, Content-Type: application/json.
  • Body: { "method": "<...>", "payload": { ... } }.
  • Response: Any 2xx (200/201/202) is treated as success — the event is accepted for processing. The server does not parse the response body.

9.3 Retries and timeouts

In case of a non-2xx response or a network error, Flowlu performs up to 3 attempts with exponential backoff [500, 1000, 2000] ms:

AttemptWhen
1Immediately
20.5s after Attempt 1 failure
31s after Attempt 2 failure

After the 3rd failed attempt, the channel's failure counter is incremented. On the 10th consecutive failure, the channel is automatically deactivated, and you will receive a bot.deactivated hook with reason: "auto_unreachable".

Response classification:

  • 200/201/202 → success. Failure counter reset.
  • 400–499 → logical refusal (your server responded but refused to process). The channel is not deactivated, but the corresponding manager message will be marked as "undelivered." No retry is performed.
  • 5xx, timeouts, DNS/TCP/TLS errors → infrastructure failure. Up to 3 attempts with backoff will be performed; the failure counter is incremented.
  • Redirects (3xx) are prohibited — respond with 200 directly.

The timeout for an individual request is roughly tens of seconds. Make processing fast; move heavy work to a background task.

9.4 message.new.personal — manager sent a message

{
  "method": "message.new.personal",
  "payload": {
    "channel_id": "<your bot_token>",
    "inner_message_id": "9001",
    "external_chat_id": "",
    "text": "Hello! How can I help you?",
    "timestamp": 1710752400,
    "event_id": "<opaque token>",
    "attachments": []
  }
}
FieldTypeDescription
channel_idstringYour bot_token provided during channel creation. Use it to identify the integration if you have multiple channels on one webhook_url
inner_message_idstringMessage ID in Flowlu. Save it — you'll need it for message.completed.personal
external_chat_idstringChat ID in your system (you sent it in message.new.personal)
textstringMessage text
timestampintUnix timestamp of sending
event_idstringOpaque event token. Only needed if you send an error response
attachmentsarrayList of attachments

What to do:

  1. Respond with 200 immediately.
  2. Find the chat by external_chat_id and deliver the message to the user.
  3. After delivery — send message.completed.personal with inner_message_id and your external_message_id.
  4. On error — send error with the provided event_id.

9.5 chat.init.personal — manager initiates a chat

The manager clicked "Write First." The chat on the Flowlu side hasn't been created yet — it will appear when your application sends message.new.personal with a new external_chat_id.

Available only if the channel has can_write_first = true.

{
  "method": "chat.init.personal",
  "payload": {
    "channel_id": "<your bot_token>",
    "to": {
      "phone": "+1234567890",
      "email": "[email protected]",
      "name":  "John Doe",
      "other": null
    },
    "message": { "text": "Hello!" },
    "event_id": "<opaque token>"
  }
}
FieldTypeDescription
channel_idstringYour bot_token
toobjectRecipient
message.textstringText of the first message
event_idstringOpaque event token

What to do:

  1. Respond with 200 immediately.
  2. Find/create the user on your side using the to fields (prioritize as you see fit: phone → email → other → name).
  3. Open a chat with this user and get your external_chat_id.
  4. Deliver message.text to the user.
  5. Send message.new.personal with this external_chat_id and direction: 1 (echo from the manager) — this creates the chat in Flowlu.
  6. On error — send error with the provided event_id.

9.6 bot.activated / bot.deactivated / bot.deleted

Channel lifecycle events. Fire-and-forget — the action is already completed on the Flowlu side; your response is only needed for logs. Return 2xx.

{
  "method": "bot.deactivated",
  "payload": {
    "channel_id": "<your bot_token>",
    "reason": "manual"
  }
}
{
  "method": "bot.activated",
  "payload": { "channel_id": "<your bot_token>" }
}
{
  "method": "bot.deleted",
  "payload": { "channel_id": "<your bot_token>" }
}

Possible reason values for bot.deactivated:

ValueDescription
manualAccount administrator manually disabled the channel
auto_unreachableFlowlu automatically deactivated the channel due to a consistently inaccessible webhook_url (10 consecutive failures). After your server is accessible again, the admin must enable the channel manually
subscription_expiredFlowlu account subscription expired

10. Data objects

10.1 UserData

Used in message.new.personal. All fields are optional.

{
  "name":        "John Doe",
  "username":    "john",
  "phone":       "+1234567890",
  "email":       "[email protected]",
  "avatar_url":  "https://example.com/avatar.jpg",
  "public_link": "https://example.com/john"
}
FieldDescription
nameFull name. Split into first/last by the first space
usernameNickname/login in your system
phonePhone number
emailEmail address
avatar_urlAvatar URL (will be downloaded)
public_linkPublic link to the user profile in your application

10.2 Attachment (inbound)

{
  "id": "att_1",
  "type": "photo",
  "url": "https://files.example.com/att_1.jpg",
  "filename": "screenshot.jpg",
  "size": 245678,
  "metadata": null
}
FieldTypeRequiredDescription
idstringyesUnique ID of the attachment in your system
urlstringyesHTTPS URL for downloading. Must be accessible to the Flowlu server
filenamestringyesFilename with extension
typestringno (default file)Type: photo, file, video, audio, location
sizeintnoSize in bytes. Used for limit pre-check
metadataobjectnoArbitrary JSON object

The file is downloaded immediately after the webhook is received. If the URL is temporary, it must remain valid for at least a few seconds after sending the webhook.

10.3 Attachment (outbound)

The structure is identical to inbound: id, type, url, filename, size, metadata.

  • id — File ID in Flowlu (sent as a string).
  • type is determined by MIME: image/* → photo, video/* → video, audio/* → audio, others → file.
  • url — One-time download link. Download immediately; do not store the URL for long.

10.4 To

Used in chat.init.personal. At least one field must be non-empty.

{
  "phone": "+1234567890",
  "email": "[email protected]",
  "name":  "John Doe",
  "other": "custom_user_id"
}
FieldDescription
phoneRecipient phone number in international format
emailRecipient email address
nameRecipient name (when phone/email are unavailable)
otherArbitrary identifier in your system

11. Limits

ParameterValue
Max MiniApp channels per account10
Number of attachments in one message10
Maximum file size50 MB
Maximum image size50 MB
Outbound webhook delivery attempts3
Backoff between attempts0.5s / 1s / 2s
Auto-deactivation threshold10 consecutive failures
Allowed webhook_url schemeHTTPS only

12. Error handling and idempotency

12.1 Idempotency of your requests

  • message.new.personal is deduplicated by external_message_id within a thread. Safe to retry — a duplicate will return 200 OK without creating a second message.
  • message.completed.personal is idempotent: after the first confirmation, repeated requests for the same inner_message_id will return 200.
  • message.edit.personal and message.delete.personal are not strictly idempotent (repeated edit will overwrite text, repeated delete will return 404). Do not retry without reason.

12.2 Idempotency of outbound webhooks

Flowlu does not guarantee exactly-once delivery: the same webhook may arrive multiple times (e.g., your server responded 200, but the response was lost in the network — a retry will follow).

Build your processing idempotently. Each outbound hook transmits a unique event_id — no two hooks have the same event_id, so it’s useful as a deduplication key: remember processed event_id values and simply return 200 on repetition.

For message.new.personal, you can additionally use inner_message_id: if the message has already been delivered to the user, repeat the 200 response without redelivery.

12.3 Channel deactivated — what happens

  • All inbound hooks to /external/rest/contactcenter/bot/hook_miniapp/... return 409 Conflict.
  • Outbound hooks are not sent to your webhook_url.

Only an account administrator can activate a channel (via the Contact Center UI or your connection page). After activation, you will receive a bot.activated hook.

12.4 Cross-bot 404 errors

If you attempt to edit/delete/confirm a message that belongs to another channel in the same account, the API will return 404. This is a security measure to ensure one MiniApp cannot affect another's messages.

13. Complete end-to-end scenario

13.1 Channel connection by manager

1. Manager opens Contact Center → "Connect Channel" → selects your integration
   │
   ▼
2. Flowlu opens an iframe with your connection page and sends a POST:
   { domain, account.id, app.id, auth.access_token, ... }
   │
   ▼
3. Your connection page:
   - requests the channel name / other settings from the manager
   - POST https://{domain}/api/v1/module/contactcenter/bot/create
     with Bearer {auth.access_token}
     { name, webhook_url, bot_token, active: 1 }
   │
   ▼
4. Flowlu returns: { data: { id, uuid, ... } }
   │
   ▼
5. You save uuid (=bot_id) and id. Close the iframe via JS SDK.

13.2 User types, manager replies

1. User types "Hello" in your app
   │
   ▼
2. POST https://{domain}/external/rest/contactcenter/bot/hook_miniapp/{account_id}/{bot_id}
   {
     "method": "message.new.personal",
     "payload": {
       "external_message_id": "msg_001",
       "external_chat_id":    "chat_42",
       "external_user_id":    "user_42",
       "text": "Hello",
       "send_date": 1710752400,
       "user_data": { "name": "John", "phone": "+1234567890" },
       "attachments": []
     }
   }
   ← 200 { "success": true }
   │
   ▼
3. Manager replies "Hello! How can I help?"
   │
   ▼
4. Flowlu → POST <your webhook_url>
   {
     "method": "message.new.personal",
     "payload": {
       "channel_id":       "<your bot_token>",
       "inner_message_id": "9001",
       "external_chat_id": "chat_42",
       "text": "Hello! How can I help?",
       "timestamp": 1710752700,
       "event_id":  "<token>",
       "attachments": []
     }
   }
   ← 200 (you respond immediately)
   │
   ▼
5. You deliver the message to the user, receive your ID msg_xyz_789
   │
   ▼
6. POST https://{domain}/external/rest/contactcenter/bot/hook_miniapp/{account_id}/{bot_id}
   {
     "method": "message.completed.personal",
     "payload": {
       "inner_message_id": 9001,
       "external_message_id": "msg_xyz_789"
     }
   }
   ← 200 { "success": true }
   │
   ▼
7. Manager's message status changes from "sending" → "delivered".

13.3 Manager types first

1. Manager clicks "Write First" (available if can_write_first = true)
   │
   ▼
2. Flowlu → POST <your webhook_url>
   {
     "method": "chat.init.personal",
     "payload": {
       "channel_id": "<your bot_token>",
       "to": { "phone": "+79001234567" },
       "message": { "text": "I saw your inquiry..." },
       "event_id": "<token>"
     }
   }
   ← 200
   │
   ▼
3. You find the user by phone, open chat external_chat_id=chat_99,
   deliver the message
   │
   │  if failed — POST .../hook_miniapp/...
   │    { "method": "error", "payload": { "event_id": "<token>", "message": "user not found" } }
   │
   ▼
4. POST .../hook_miniapp/...
   {
     "method": "message.new.personal",
     "payload": {
       "external_message_id": "msg_init_1",
       "external_chat_id":    "chat_99",
       "external_user_id":    "user_42",
       "text": "I saw your inquiry...",
       "send_date": 1710752800,
       "user_data": { ... },
       "direction": 1
     }
   }
   ← 200
   │
   ▼
5. A thread with external_chat_id=chat_99 is created in Flowlu. Manager sees the chat.

14. Code examples

Placeholders used in examples:

  • {domain} — my.flowlu.com (value from the iframe POST);
  • {account_id} — 123456;
  • {bot_id} — 550e8400-e29b-41d4-a716-446655440000 (channel UUID);
  • {access_token} — User OAuth2 token.

14.1 curl: create a channel

curl -X POST 'https://{domain}/api/v1/module/contactcenter/bot/create' \
  -H 'Authorization: Bearer {access_token}' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "My Channel",
    "webhook_url": "https://example.com/flowlu-webhook/8f7a3c",
    "bot_token": "my-integration-id-42",
    "active": 1,
    "can_write_first": true
  }'

14.2 curl: send a new message

curl -X POST 'https://{domain}/external/rest/contactcenter/bot/hook_miniapp/{account_id}/{bot_id}' \
  -H 'Content-Type: application/json' \
  -d '{
    "method": "message.new.personal",
    "payload": {
      "text": "Hello!",
      "send_date": 1710752400,
      "external_message_id": "msg_001",
      "external_user_id":    "user_42",
      "external_chat_id":    "chat_42",
      "attachments": [],
      "user_data": { "name": "John Doe", "phone": "+1234567890" }
    }
  }'

14.3 curl: confirm delivery

curl -X POST 'https://{domain}/external/rest/contactcenter/bot/hook_miniapp/{account_id}/{bot_id}' \
  -H 'Content-Type: application/json' \
  -d '{
    "method": "message.completed.personal",
    "payload": {
      "inner_message_id":    9001,
      "external_message_id": "msg_xyz_789"
    }
  }'

14.4 Node.js: webhook receiver and client

import express from 'express';
import axios from 'axios';

const DOMAIN     = 'my.flowlu.com';
const ACCOUNT_ID = 123456;
const BOT_ID     = '550e8400-e29b-41d4-a716-446655440000';
const BOT_TOKEN  = 'my-integration-id-42';

const hookUrl = `https://${DOMAIN}/external/rest/contactcenter/bot/hook_miniapp/${ACCOUNT_ID}/${BOT_ID}`;

const app = express();
app.use(express.json({ limit: '10mb' }));

// --- Outbound Webhook Receiver from Flowlu ---
app.post('/flowlu-webhook', async (req, res) => {
  const { method, payload } = req.body;

  if (payload?.channel_id && payload.channel_id !== BOT_TOKEN) {
    return res.status(400).json({ error: 'unknown channel' });
  }

  // Respond with 200 immediately, run processing asynchronously.
  res.status(200).json({ ok: true });

  try {
    switch (method) {
      case 'message.new.personal':  await deliverManagerMessage(payload); break;
      case 'chat.init.personal':    await openManagerInitiatedChat(payload); break;
      case 'bot.activated':
      case 'bot.deactivated':
      case 'bot.deleted':
        console.log(`Lifecycle: ${method}`, payload);
        break;
      default:
        console.warn('Unknown method', method);
    }
  } catch (err) {
    if (payload?.event_id) {
      await reportError(payload.event_id, err.message).catch(() => {});
    }
  }
});

app.listen(3000);

// --- Deliver manager message to user + confirmation ---
async function deliverManagerMessage(payload) {
  const { inner_message_id, external_chat_id, text, attachments } = payload;

  const externalMessageId = await sendToUser(external_chat_id, text, attachments);

  await axios.post(hookUrl, {
    method: 'message.completed.personal',
    payload: { inner_message_id, external_message_id: externalMessageId },
  });
}

// --- Manager initiated chat ---
async function openManagerInitiatedChat(payload) {
  const { to, message, event_id } = payload;

  const user = await findUserByContact(to);
  if (!user) throw new Error('user not found');

  const chatId = await createOrOpenChat(user.id);
  await sendToUser(chatId, message.text);

  // Echo to Flowlu — without this the thread won't be created
  await axios.post(hookUrl, {
    method: 'message.new.personal',
    payload: {
      external_message_id: `init_${Date.now()}`,
      external_chat_id: chatId,
      external_user_id: user.id,
      text: message.text,
      send_date: Math.floor(Date.now() / 1000),
      user_data: { name: user.name, phone: user.phone, email: user.email },
      attachments: [],
      direction: 1,
    },
  });
}

// --- Report delivery error ---
async function reportError(eventId, message) {
  await axios.post(hookUrl, {
    method: 'error',
    payload: { event_id: eventId, message },
  });
}

// --- When end-user writes to manager ---
async function userSentMessage({ userId, chatId, messageId, text, userData }) {
  await axios.post(hookUrl, {
    method: 'message.new.personal',
    payload: {
      text,
      send_date: Math.floor(Date.now() / 1000),
      external_message_id: messageId,
      external_user_id: userId,
      external_chat_id: chatId,
      attachments: [],
      user_data: userData,
    },
  });
}

// Stubs — implement for your stack
async function sendToUser(chatId, text, attachments) { /* ... */ return `local_${Date.now()}`; }
async function findUserByContact(to) { /* ... */ }
async function createOrOpenChat(userId) { /* ... */ }

14.5 PHP: minimal client

post([
            'method' => 'message.new.personal',
            'payload' => [
                'text' => $text,
                'send_date' => time(),
                'external_message_id' => $externalMessageId,
                'external_user_id' => $externalUserId,
                'external_chat_id' => $externalChatId,
                'attachments' => $attachments,
                'user_data' => $userData,
            ],
        ]);
    }

    public function confirmDelivery(int $innerMessageId, string $externalMessageId): void
    {
        $this->post([
            'method' => 'message.completed.personal',
            'payload' => [
                'inner_message_id' => $innerMessageId,
                'external_message_id' => $externalMessageId,
            ],
        ]);
    }

    public function reportError(string $eventId, string $message): void
    {
        $this->post([
            'method' => 'error',
            'payload' => ['event_id' => $eventId, 'message' => $message],
        ]);
    }

    private function post(array $body): void
    {
        $url = sprintf(
            'https://%s/external/rest/contactcenter/bot/hook_miniapp/%d/%s',
            $this->domain,
            $this->accountId,
            $this->botId
        );

        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_POST => true,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
            CURLOPT_POSTFIELDS => json_encode($body),
            CURLOPT_TIMEOUT => 10,
        ]);
        $response = curl_exec($ch);
        $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($code < 200 || $code >= 300) {
            throw new RuntimeException("Flowlu hook failed: HTTP $code, body: $response");
        }
    }
}

$client = new FlowluMiniAppClient('my.flowlu.com', 123456, '550e8400-e29b-41d4-a716-446655440000');
$client->sendUserMessage(
    'msg_001',
    'chat_42',
    'user_42',
    'Hello!',
    ['name' => 'John Doe', 'phone' => '+1234567890']
);

FAQ

Can I use one webhook_url for multiple channels?

Yes. Distinguish them by channel_id (= bot_token set during each channel's creation).

What should I pass in bot_token — some fixed secret?

It is the integration identifier on your side, not a secret. It’s convenient to use your own UUID or the channel ID from your database. The main thing is that it is unique within your application and immutable.

What should I pass in user_data for a manager's echo message (direction=1)?

The interlocutor's (client's) data, not the manager's. The CRM uses this to link to the client profile.

What should I pass in external_user_id for a manager's outbound message from another device?

The interlocutor's ID, not the sender's. The message is associated with the chat where that interlocutor is communicating in any case.

The message doesn't appear in Flowlu — what should I check?

  1. Is the channel active (active = 1)?
  2. Is the request URL correct: /external/rest/contactcenter/bot/hook_miniapp/{account_id}/{bot_id}?
  3. Is the response 200 { "success": true }? 400 — payload invalid, 404 — wrong channel/message, 409 — channel deactivated, 410 — channel deleted.
  4. Is external_message_id unique within the thread? Duplicates are no-ops.

The manager types, but my webhook isn't called, why?

  1. Is webhook_url set in the channel? Check via an API request to read the channel.
  2. Does the URL respond within a reasonable time (a few seconds) and with a 2xx code?
  3. Is the URL HTTPS and publicly accessible?
  4. Has the auto-deactivation threshold been reached? If the server responded with errors 10 times, the channel is disabled and awaits manual activation.

What should I do if I receive a duplicate outbound webhook?

Build your processing idempotently: for message.new.personal, use event_id — if the event has already been processed, return 200 OK without further action.

Can I change the webhook_url of an existing channel?

Yes, via an API request to update the channel with a new webhook_url.

Where can I get the client's account_id?

In the POST request to the iframe, in the account.id field. This is a public account identifier used in inbound webhook URLs.

Where can I get the client's domain?

In the POST request to the iframe, in the domain field. Use it as the base URL for all API requests to that account.

Are read-receipts ("message read") supported?

No. The message.read.personal method is reserved but not processed in the current version.

Previous Building a Telephony Module Application