Skip to main content

Send Bot Messages in Batch in Bot-to-Human Sessions

A third-party app uses this API to send one-to-one messages, including text and structured cards, to one or more target members as an internal application robot.

The server creates or reuses a bot-to-human session for each target member, delivers messages concurrently, and returns an item-level result for every target user.

API information​

ItemDescription
API nameSend bot messages in batch in bot-to-human sessions
Permission scoperobot.push (send robot messages)
HTTP methodPOST
Endpointhttps://{gateway_host}/open/v1/robot/messages
Data formatapplication/json
AuthenticationApp-level access_token with the robot.push permission
Idempotency controlRequired Idempotency-Key request header

Note: This API is the application-robot channel and uses an app-level access_token. A custom Webhook robot is a separate channel that uses request signing; do not mix the credentials from the two channels.

Prerequisites​

  1. App status: The app has a published version and is enabled under Internal Apps in the admin console.
  2. Robot enabled: Robot capability is enabled in the app details, and the app's robotCode has been recorded.
  3. Permissions and credentials: A valid app-level access_token has been obtained, and the token has the robot.push permission.
  4. User visibility scope: Target recipients in userids must be within the app's visibility scope. Users outside that scope are rejected individually.

Application robot integration steps​

An application robot sends one-to-one messages to members as the app, using the app access_token and robotCode. It is suitable for alerts, to-do items, and business notifications.

Integration steps​

  1. Enable robot capability in the app configuration and publish the app.
  2. Obtain the server-side robotCode; never send it to a client.
  3. Obtain an app-level access_token.
  4. Send messages with this API. Passing multiple target users makes the request a batch send.

Application robots support text and card messages. See Message types and card fields for their structure.

Minimal examples in four languages​

Shell​

curl -sS -X POST "https://{gateway_host}/open/v1/robot/messages" \
-H "Authorization: Bearer access-token-xxx" -H "Content-Type: application/json" \
-H "Idempotency-Key: robot-demo-001" \
-d '{"robotCode":"robot_xxx","userids":["user-001"],"msg":{"mtype":"text","content":"Service notification"}}'

PHP​

<?php
// Keep the robot token and idempotency key on the server.
$ch = curl_init('https://{gateway_host}/open/v1/robot/messages');
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer access-token-xxx', 'Content-Type: application/json', 'Idempotency-Key: robot-demo-001'], CURLOPT_POSTFIELDS => json_encode(['robotCode' => 'robot_xxx', 'userids' => ['user-001'], 'msg' => ['mtype' => 'text', 'content' => 'Service notification']], JSON_THROW_ON_ERROR), CURLOPT_RETURNTRANSFER => true]);
echo curl_exec($ch); curl_close($ch);

Go​

package main

import ("bytes"; "net/http")

func main() {
// Keep the idempotency key and request body unchanged when retrying.
body := []byte(`{"robotCode":"robot_xxx","userids":["user-001"],"msg":{"mtype":"text","content":"Service notification"}}`)
request, _ := http.NewRequest(http.MethodPost, "https://{gateway_host}/open/v1/robot/messages", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer access-token-xxx"); request.Header.Set("Content-Type", "application/json"); request.Header.Set("Idempotency-Key", "robot-demo-001")
response, _ := http.DefaultClient.Do(request); defer response.Body.Close()
}

C++​

#include <curl/curl.h>

int main() {
// Production code should inspect item-level results and record the request ID.
CURL* handle = curl_easy_init();
const char* body = R"({"robotCode":"robot_xxx","userids":["user-001"],"msg":{"mtype":"text","content":"Service notification"}})";
struct curl_slist* headers = nullptr; headers = curl_slist_append(headers, "Authorization: Bearer access-token-xxx"); headers = curl_slist_append(headers, "Content-Type: application/json"); headers = curl_slist_append(headers, "Idempotency-Key: robot-demo-001");
curl_easy_setopt(handle, CURLOPT_URL, "https://{gateway_host}/open/v1/robot/messages"); curl_easy_setopt(handle, CURLOPT_HTTPHEADER, headers); curl_easy_setopt(handle, CURLOPT_POSTFIELDS, body);
const CURLcode result = curl_easy_perform(handle); curl_slist_free_all(headers); curl_easy_cleanup(handle); return result == CURLE_OK ? 0 : 1;
}

Request parameters​

Request headers​

Request headerRequiredDescription
Content-TypeYesMust be application/json
AuthorizationOne of twoBearer <access_token>; the recommended token transport method
access_token query parameterOne of twoAlternative token transport, for example ?access_token=xxx
X-Request-IdYesGlobally unique, non-blank request correlation ID. The value is echoed in requestId in the response.
Idempotency-KeyYesClient-generated idempotency key that prevents duplicate messages during retries

Constraints:

  • The token may be sent only in the Authorization header or the query parameter. If both are sent with different values, the request is rejected.
  • X-Secret is an internal shared secret injected by the trusted gateway; callers must never forge it.

Request body​

{
"robotCode": "robot_7f3c1a2b-0d4e-4f5a-9b6c-8d7e6f5a4b3c",
"userids": ["10001", "10002"],
"msg": {
"mtype": "text",
"content": "Your verification code is 482913 and is valid for 5 minutes."
}
}
ParameterTypeRequiredDescription
robotCodestringYesUnique invocation identifier of the application robot
useridsstring[]YesTarget user ID list. IDs must be positive integer strings, non-empty, and unique.
msgobjectYesMessage object
msg.mtypestringYesMessage type: text or card
msg.contentstringConditionalRequired for text; optional for card. Card rendering and offline notification text are taken from msg.ext.
msg.extobjectConditionalRequired for card and must be a JSON object. A JSON string is rejected.

Field constraints:

  • Send userids as a string array to avoid integer precision loss. IDs must be positive integers and must not be duplicated in one request.

Message types and card structure​

Text message (text)​

Set mtype to text and put the message body in content:

{
"robotCode": "robot_xxx",
"userids": ["10001"],
"msg": {
"mtype": "text",
"content": "The system will be upgraded at 22:00 tonight."
}
}

Structured card message (card)​

Set mtype to card and put the card structure in msg.ext. msg.ext must be a JSON object:

{
"robotCode": "robot_xxx",
"userids": ["10001", "10002"],
"msg": {
"mtype": "card",
"content": "",
"ext": {
"title": "Release announcement",
"content": "The release window is approaching. Please complete the final review.",
"img": "https://xxx.png",
"button": [{
"type": "normal",
"text": "Handle later",
"actionType": "url",
"action": "https://example.com/release-notes"
}]
}
}
}

Card field reference (msg.ext)​

FieldTypeRequiredDescription
titlestringNoCard title, up to 64 characters
contentstringNoCard body, up to 2,000 characters
imgstringNoCard cover image URL; when non-empty, it must be an absolute HTTP/HTTPS URL
buttonobject[]NoAction button group, with up to 3 buttons
button[].typestringYesButton style: normal or primary
button[].textstringYesButton label
button[].actionTypestringYesButton action type: url or none
button[].actionstringConditionalButton URL, required when actionType="url"

Field constraints:

  • msg.ext.title and msg.ext.content are counted as Unicode characters. A missing or explicit null value is treated as unset. title is limited to 64 characters and content to 2,000 characters.
  • If either field exceeds its limit, the entire batch request is rejected with card title is too long or card content is too long.
  • If a text field is a number, object, array, or another non-string value, the entire batch is rejected with invalid card title or invalid card content.

Offline notification copy:

  • When the recipient is offline, the notification first uses msg.ext.title. If the title is empty, it uses msg.ext.content; if both are empty, it uses the fallback text You have a new message, truncated to 80 characters for display.

Message types and card fields​

This section describes the mtype, content, and ext structures and the client display rules.

Only the open API (POST /open/v1/robot/messages) supports the text and card message types. The group Webhook channel has separate authentication and supported message types.

Message structure​

In the open API, message content is passed through the msg object:

{
"mtype": "text",
"content": "Message body",
"ext": {}
}
FieldTypeDescription
mtypestringMessage type: text or card
contentstringText content; required for mtype=text and unused for mtype=card
extobjectStructured extension; required for mtype=card and unused for mtype=text

Text message​

The minimal form is suitable for plain-text notifications such as verification codes and status reminders.

{
"mtype": "text",
"content": "Your verification code is 482913 and is valid for 5 minutes."
}
ConstraintDescription
content requiredAn empty value returns content is required for the entire batch.
LengthThere is no hard limit; keep the text within a length that the client can display completely.

Card message​

Cards carry structured notifications with a title, body, image, and navigation buttons.

Outer structure​

For mtype=card, all card content is placed in ext, which must be a JSON object:

{
"mtype": "card",
"content": "",
"ext": {
"title": "Release announcement",
"content": "The release window is approaching. Please complete the final review.",
"img": "https://xxx.png",
"button": [{ "type": "normal", "text": "Handle later", "actionType": "url", "action": "https://example.com/release-notes" }]
}
}

ext fields​

FieldTypeRequiredDescription
titlestringNoCard title; omitted from display when empty
contentstringNoCard body; omitted from display when empty
imgstringNoCard image URL; omitted from display when empty
buttonobject[]NoCard buttons; omitted from display when empty

No business field inside ext is individually required: a card with only title or only buttons is valid. However, ext itself must exist and be a JSON object; an array or string returns card ext is invalid.

Text length limits​

title and content are counted as Unicode characters. A missing or explicit null value is treated as unset and is not validated. The two fields occupy different positions on the card and have independent limits:

FieldLimitError when exceeded
title64 characterscard title is too long
content2,000 characterscard content is too long
CaseResult
Each field is within its own limitAccepted
A field exceeds its limitRejected with card title is too long or card content is too long
A field is a number, object, array, or other non-string valueRejected with invalid card title or invalid card content

Limits are calculated per field; title and content are not combined. A validation failure rejects the entire batch, so no user in that batch receives a message.

Offline notification copy​

When the recipient app is offline or in the background, the card is delivered through the system notification bar. The copy is selected in this order:

OrderValueDescription
1ext.titleUsed first when the trimmed title is non-empty
2ext.contentUsed when the title is empty
3You have a new messageFallback when both title and body are empty

Notification copy is truncated to 80 characters. Structured fields such as ext.img and ext.button do not appear in the notification bar. When multiple messages accumulate for the same session during the push aggregation window, the notification bar shows You have N new messages instead of a single-message copy.

The copy for fallback text, aggregation templates, and placeholders for image, voice, video, file, location, and call-record messages is defined by the notification-copy table in common/constant/message.go. The current table is fixed to Simplified Chinese; multilingual notification copy is planned for a later version.

Button fields​

FieldTypeDescription
typestringButton style: normal or primary
textstringButton label
actionTypestringButton action type; only url is currently supported for navigation
actionstringAction value; for actionType=url, this is the web address to open

Button display and navigation rules:

  • Card button URLs are opened by the client's built-in browser.

  • The first row displays at most two buttons; each remaining button occupies a full row:

    Button countLayout
    1The button occupies the full row
    2Two buttons side by side on the first row
    3 or moreTwo buttons on the first row, then one button per row

URL validation rules​

Every navigation URL in a card must use the http:// or https:// scheme.

The following fields are validated and must be absolute HTTP/HTTPS URLs:

FieldValidation condition
ext.imgValidated whenever it is non-empty
ext.button[].actionValidated only when that button's actionType is url
ValueResult
Absolute HTTP/HTTPS URL, such as https://example.com/aAccepted
URL without a scheme, such as www.example.comRejected
Custom schemes such as javascript:, file:, or data:Rejected
Empty stringAccepted; treated as unset

Validation failure rejects the entire batch and no user in the batch receives a message. The API returns only a unified system error and does not identify the invalid field or value; inspect the card content when troubleshooting.

The button action is validated only when actionType is url. If actionType is missing or has another value, the address is not validated and the client does not treat it as a web navigation. Always send actionType: "url" with a complete scheme for link buttons.

Display rules​

The following rules affect the final message display:

  • Empty title, content, img, or button fields do not display their corresponding area.
  • Card content is not automatically completed and has no fallback copy.
  • If card parsing fails, display fields may be empty.

Session list summary​

The bot session-list summary is selected in this order:

Non-empty ext.title -> use title
Empty ext.title -> use ext.content
Both empty -> use the unified card placeholder

When users search, searchable card text contains only title and content; images, button labels, and URLs are excluded.

Channel differences​

The card type is available only on the application-robot channel (POST /open/v1/robot/messages).

The group-robot Webhook channel (POST /webhook/robot/send) supports text, image, voice, video, file, location, and custom; it does not support card.

Idempotency and retry​

ItemDescription
Idempotency keyRequired Idempotency-Key request header
ScopeUnique per app and valid for 24 hours
Semantic validationThe server records a digest fingerprint of the request body
Same payload submitted againTreated as a timeout or duplicate retry; the previous result is returned without producing a duplicate message
A different payload reuses the same keyThe server rejects the request with IDEMPOTENCY_KEY_REUSED

Retry guidance: After a network timeout or an ambiguous response, retry with exactly the same Idempotency-Key and request body.

Response parameters​

Successful response (HTTP 200, platform unified envelope):

{
"code": 200,
"msg": "",
"data": {
"requestId": "req-1710000000000000000",
"results": [
{"index": 0, "userId": "10001", "status": "queued", "sessionId": "30001", "messageId": "1234567890123456789", "seq": "102"},
{"index": 1, "userId": "10002", "status": "rejected", "errorCode": "USER_NOT_ALLOWED", "errorMessage": "user is not allowed"}
],
"summary": {"total": 2, "success": 1, "failed": 1}
}
}

Response field reference​

FieldTypeDescription
codeintegerBusiness status; 200 means the request was accepted for processing
msgstringError message; empty on success
data.requestIdstringValue corresponding to the X-Request-Id request header
data.resultsobject[]Item-level delivery results in the same order as userids
data.results[].indexintegerZero-based index in the request's userids list
data.results[].userIdstringTarget user ID
data.results[].statusstringDelivery status: queued, duplicate, rejected, or failed
data.results[].sessionIdstringBot-to-human session ID; returned on success
data.results[].messageIdstringUnique message ID (MID); returned on success
data.results[].seqstringStrictly increasing sequence number within the session; returned on success
data.results[].errorCodestringError code when delivery is rejected or fails
data.results[].errorMessagestringDetailed error description when delivery is rejected or fails
data.summaryobjectSummary statistics
data.summary.totalintegerTotal number of target users
data.summary.successintegerNumber entering the delivery queue; queued and duplicate count as success
data.summary.failedintegerNumber rejected or failed; rejected and failed count as failure

Delivery status values (status)​

  • queued: The message was accepted and written to the message queue and will be delivered to the client.
  • duplicate: The idempotency cache was hit; the message was delivered successfully before.
  • rejected: The target user failed permission validation, such as being outside the visibility scope, nonexistent, or disabled.
  • failed: Server-side session creation or message delivery failed.

Error codes​

Authentication and transport errors (HTTP status)​

HTTP statusmsg error codeDescription and handling
400INVALID_REQUESTContent-Type is not application/json, or X-Request-Id is missing
401INVALID_TOKENToken is missing, expired, invalid, or the app has been disabled or deleted
403SCOPE_DENIEDToken lacks the robot.push permission
403IP_DENIEDRequest IP is not in the app's IP allowlist
503AUTH_UNAVAILABLEAuthentication dependency is temporarily unavailable

Request-level business errors (HTTP 200, code != 200)​

msg error codeDescription
missing idempotency keyThe request is missing the Idempotency-Key header
invalid requestRequired request data is missing, such as robotCode, userids, or msg.mtype
content is requiredcontent is empty for a text message
card ext is invalidCard ext is missing, not a valid JSON object, or supplied as a string
card title is too longCard ext.title exceeds 64 characters
card content is too longCard ext.content exceeds 2,000 characters
invalid card titleCard ext.title is not a string, such as a number, object, or array
invalid card contentCard ext.content is not a string, such as a number, object, or array
unsupported message typemtype is not text or card
robot unavailableRobot or app is disabled, unpublished, or in an abnormal state
invalid user id at index NThe user ID at index N is not a valid positive integer string
duplicate user idThe userids list contains a duplicate user ID
IDEMPOTENCY_KEY_REUSEDThe same key was bound to a different request body
IDEMPOTENCY_UNAVAILABLEIdempotency storage is unavailable; retry later

Per-user error codes (results[].errorCode)​

errorCodestatusDescription
USER_NOT_ALLOWEDrejectedTarget user is outside the app's visibility scope or does not exist
USER_UNAVAILABLErejected / failedUser is unavailable because the account is disabled or deleted
SESSION_FAILEDfailedCreating or querying the bot-to-human session failed
SESSION_TYPE_INVALIDfailedThe session service returned a non-bot session type
MESSAGE_FAILEDfailedDelivery to the message queue failed

Call examples​

Shell (cURL) complete example​

#!/usr/bin/env bash
set -euo pipefail

# Basic configuration
GATEWAY_HOST="https://im-gateway.example.com"
APP_ID="app_10001"
APP_SECRET="link_sec_xxxxxxxxxxxxxxxxxxxx"
ROBOT_CODE="robot_7f3c1a2b-0d4e-4f5a-9b6c-8d7e6f5a4b3c"

# Obtain the app-level access_token
echo "==> Requesting access_token..."
TOKEN_RESP=$(curl -s -X POST "${GATEWAY_HOST}/auth/v1/oauth/token" -H "Content-Type: application/json" -d "{\"client_id\":\"${APP_ID}\",\"client_secret\":\"${APP_SECRET}\",\"grant_type\":\"client_credentials\"}")
ACCESS_TOKEN=$(echo "${TOKEN_RESP}" | grep -o '\"access_token\":\"[^\"]*' | cut -d'\"' -f4)
if [ -z "${ACCESS_TOKEN}" ]; then echo "Failed to obtain access_token: ${TOKEN_RESP}"; exit 1; fi
echo "==> Token obtained: ${ACCESS_TOKEN:0:10}..."

# Build request headers and message body
REQUEST_ID="req-$(date +%s%N)"
IDEMPOTENCY_KEY="idem-$(date +%s)-${RANDOM}"
REQ_BODY=$(cat <<EOF
{"robotCode":"${ROBOT_CODE}","userids":["10001","10002"],"msg":{"mtype":"card","content":"","ext":{"title":"Release announcement","content":"The release window is approaching. Please complete the final review.","img":"https://xxx.png","button":[{"type":"normal","text":"Handle later","actionType":"url","action":"https://example.com/release-notes"}]}}}
EOF
)

# Send the robot message
echo "==> Sending robot message..."
RESP=$(curl -s -X POST "${GATEWAY_HOST}/open/v1/robot/messages" -H "Content-Type: application/json" -H "Authorization: Bearer ${ACCESS_TOKEN}" -H "X-Request-Id: ${REQUEST_ID}" -H "Idempotency-Key: ${IDEMPOTENCY_KEY}" -d "${REQ_BODY}")
echo "==> API response:"; echo "${RESP}"

Go complete example​

package main

import ("bytes"; "context"; "encoding/json"; "fmt"; "io"; "net/http"; "time")

const ( GatewayHost = "https://im-gateway.example.com"; AppID = "app_10001"; AppSecret = "link_sec_xxxxxxxxxxxxxxxxxxxx"; RobotCode = "robot_7f3c1a2b-0d4e-4f5a-9b6c-8d7e6f5a4b3c" )
type OAuthTokenResponse struct { AccessToken string `json:"access_token"`; ExpiresIn int `json:"expires_in"`; Error string `json:"error,omitempty"` }
type CardButton struct { Type string `json:"type"`; Text string `json:"text"`; ActionType string `json:"actionType"`; Action string `json:"action,omitempty"` }
type CardExt struct { Title string `json:"title,omitempty"`; Content string `json:"content,omitempty"`; Img string `json:"img,omitempty"`; Button []CardButton `json:"button,omitempty"` }
type RobotMessage struct { MType string `json:"mtype"`; Content string `json:"content,omitempty"`; Ext *CardExt `json:"ext,omitempty"` }
type SendMessageRequest struct { RobotCode string `json:"robotCode"`; UserIDs []string `json:"userids"`; Msg RobotMessage `json:"msg"` }
type UserResult struct { Index int `json:"index"`; UserID string `json:"userId"`; Status string `json:"status"`; SessionID string `json:"sessionId,omitempty"`; MessageID string `json:"messageId,omitempty"`; Seq string `json:"seq,omitempty"`; ErrorCode string `json:"errorCode,omitempty"`; ErrorMessage string `json:"errorMessage,omitempty"` }
type Summary struct { Total int `json:"total"`; Success int `json:"success"`; Failed int `json:"failed"` }
type SendMessageResponse struct { Code int `json:"code"`; Msg string `json:"msg"`; Data struct { RequestID string `json:"requestId"`; Results []UserResult `json:"results"`; Summary Summary `json:"summary"` } `json:"data"` }
var httpClient = &http.Client{Timeout: 10 * time.Second}

func FetchAccessToken(ctx context.Context, host, appID, appSecret string) (string, error) {
reqBody, _ := json.Marshal(map[string]string{"client_id": appID, "client_secret": appSecret, "grant_type": "client_credentials"})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, host+"/auth/v1/oauth/token", bytes.NewReader(reqBody)); if err != nil { return "", err }
req.Header.Set("Content-Type", "application/json"); resp, err := httpClient.Do(req); if err != nil { return "", fmt.Errorf("token request failed: %w", err) }; defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body); if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("token request returned HTTP %d: %s", resp.StatusCode, string(bodyBytes)) }
var tokenResp OAuthTokenResponse; if err := json.Unmarshal(bodyBytes, &tokenResp); err != nil { return "", fmt.Errorf("decode token response: %w", err) }; return tokenResp.AccessToken, nil
}

func SendRobotMessage(ctx context.Context, host, token, reqID, idemKey string, payload SendMessageRequest) (*SendMessageResponse, error) {
bodyData, err := json.Marshal(payload); if err != nil { return nil, fmt.Errorf("encode request body: %w", err) }
req, err := http.NewRequestWithContext(ctx, http.MethodPost, host+"/open/v1/robot/messages", bytes.NewReader(bodyData)); if err != nil { return nil, err }
req.Header.Set("Content-Type", "application/json"); req.Header.Set("Authorization", "Bearer "+token); req.Header.Set("X-Request-Id", reqID); req.Header.Set("Idempotency-Key", idemKey)
resp, err := httpClient.Do(req); if err != nil { return nil, fmt.Errorf("send message HTTP request failed: %w", err) }; defer resp.Body.Close()
respBytes, err := io.ReadAll(resp.Body); if err != nil { return nil, fmt.Errorf("read response failed: %w", err) }; if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("server returned HTTP %d: %s", resp.StatusCode, string(respBytes)) }
var result SendMessageResponse; if err := json.Unmarshal(respBytes, &result); err != nil { return nil, fmt.Errorf("decode business response: %w", err) }; return &result, nil
}

func main() {
ctx := context.Background(); token, err := FetchAccessToken(ctx, GatewayHost, AppID, AppSecret); if err != nil { fmt.Printf("Failed to obtain token: %v\n", err); return }; fmt.Println("Obtained access_token")
reqPayload := SendMessageRequest{RobotCode: RobotCode, UserIDs: []string{"10001", "10002"}, Msg: RobotMessage{MType: "card", Ext: &CardExt{Title: "Release announcement", Content: "The release window is approaching. Please complete the final review.", Img: "https://xxx.png", Button: []CardButton{{Type: "normal", Text: "Handle later", ActionType: "url", Action: "https://example.com/release-notes"}}}}}
requestID := fmt.Sprintf("req-%d", time.Now().UnixNano()); idempotencyKey := fmt.Sprintf("idem-%d", time.Now().UnixNano()); resp, err := SendRobotMessage(ctx, GatewayHost, token, requestID, idempotencyKey, reqPayload)
if err != nil { fmt.Printf("Message delivery failed: %v\n", err); return }; fmt.Printf("Message send complete: code=%d, total=%d, success=%d, failed=%d\n", resp.Code, resp.Data.Summary.Total, resp.Data.Summary.Success, resp.Data.Summary.Failed)
for _, item := range resp.Data.Results { fmt.Printf(" - user %s: status=%s, session=%s, message=%s, error=%s\n", item.UserID, item.Status, item.SessionID, item.MessageID, item.ErrorCode) }
}

PHP​

<?php
// Keep card fields as nested objects and use a plain HTTP(S) URL for button actions.
$payload = ['robotCode' => 'robot_xxx', 'userids' => ['10001'], 'msg' => ['mtype' => 'card', 'content' => '', 'ext' => ['title' => 'Release announcement', 'content' => 'The release window is approaching. Please complete the final review.', 'img' => 'https://xxx.png', 'button' => [['type' => 'normal', 'text' => 'Handle later', 'actionType' => 'url', 'action' => 'https://example.com/release-notes']]]]];
$handle = curl_init('https://{gateway_host}/open/v1/robot/messages'); curl_setopt_array($handle, [CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer access-token-xxx', 'Content-Type: application/json', 'X-Request-Id: req-20260920-0002', 'Idempotency-Key: idem-20260920-0002'], CURLOPT_POSTFIELDS => json_encode($payload, JSON_THROW_ON_ERROR), CURLOPT_RETURNTRANSFER => true]);
echo curl_exec($handle); curl_close($handle);

C++​

#include <curl/curl.h>

int main() {
// The action field must be a plain URL, not Markdown link syntax.
const char* payload = R"({"robotCode":"robot_xxx","userids":["10001"],"msg":{"mtype":"card","content":"","ext":{"title":"Release announcement","content":"The release window is approaching. Please complete the final review.","img":"https://xxx.png","button":[{"type":"normal","text":"Handle later","actionType":"url","action":"https://example.com/release-notes"}]}}})";
CURL* handle = curl_easy_init(); struct curl_slist* headers = nullptr; headers = curl_slist_append(headers, "Authorization: Bearer access-token-xxx"); headers = curl_slist_append(headers, "Content-Type: application/json"); headers = curl_slist_append(headers, "X-Request-Id: req-20260920-0002"); headers = curl_slist_append(headers, "Idempotency-Key: idem-20260920-0002");
curl_easy_setopt(handle, CURLOPT_URL, "https://{gateway_host}/open/v1/robot/messages"); curl_easy_setopt(handle, CURLOPT_HTTPHEADER, headers); curl_easy_setopt(handle, CURLOPT_POSTFIELDS, payload); const CURLcode result = curl_easy_perform(handle); curl_slist_free_all(headers); curl_easy_cleanup(handle); return result == CURLE_OK ? 0 : 1;
}

Session and display rules​

Robot messages are displayed in a bot session after they reach the client. The app backend only sends the message; it must not assume that the client session already exists or opens immediately.

Display notes​

  • An application robot is displayed as the app; a group robot is displayed as the robot inside the group.
  • The session-list summary is generated by the client according to the message type. Recalled messages, cards, and attachments may use different summary text.
  • Robot messages remain subject to the target user's permissions, app status, and visibility scope.
  • Use server-returned message IDs and item-level delivery statuses for auditing; do not treat client display as proof of success.

Batch send (multiple target users)​

This API supports both one-to-one and batch sends. When userids contains multiple users, the server delivers the message per user and returns item-level results. Other than expanding userids into a target list, the headers, idempotency key, and remaining fields are identical to a one-to-one send.

{ "robotCode": "robot_xxx", "userids": ["user-001", "user-002"], "msg": { "mtype": "text", "content": "Batch notification" } }
  • code != 200 means the entire request failed; do not treat it as a partial success.
  • Process results[] item by item. rejected usually indicates a permission or visibility issue; retry failed only according to its error code.
  • Split large business workloads into batches to avoid delivering to too many members at once.
  • On a network timeout, retry with the same Idempotency-Key and request body to avoid duplicate sends.