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
| Item | Description |
|---|---|
| API name | Send bot messages in batch in bot-to-human sessions |
| Permission scope | robot.push (send robot messages) |
| HTTP method | POST |
| Endpoint | https://{gateway_host}/open/v1/robot/messages |
| Data format | application/json |
| Authentication | App-level access_token with the robot.push permission |
| Idempotency control | Required 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
- App status: The app has a published version and is enabled under Internal Apps in the admin console.
- Robot enabled: Robot capability is enabled in the app details, and the app's
robotCodehas been recorded. - Permissions and credentials: A valid app-level
access_tokenhas been obtained, and the token has therobot.pushpermission. - User visibility scope: Target recipients in
useridsmust 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
- Enable robot capability in the app configuration and publish the app.
- Obtain the server-side
robotCode; never send it to a client. - Obtain an app-level
access_token. - 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 header | Required | Description |
|---|---|---|
Content-Type | Yes | Must be application/json |
Authorization | One of two | Bearer <access_token>; the recommended token transport method |
access_token query parameter | One of two | Alternative token transport, for example ?access_token=xxx |
X-Request-Id | Yes | Globally unique, non-blank request correlation ID. The value is echoed in requestId in the response. |
Idempotency-Key | Yes | Client-generated idempotency key that prevents duplicate messages during retries |
Constraints:
- The token may be sent only in the
Authorizationheader or the query parameter. If both are sent with different values, the request is rejected. X-Secretis 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."
}
}
| Parameter | Type | Required | Description |
|---|---|---|---|
robotCode | string | Yes | Unique invocation identifier of the application robot |
userids | string[] | Yes | Target user ID list. IDs must be positive integer strings, non-empty, and unique. |
msg | object | Yes | Message object |
msg.mtype | string | Yes | Message type: text or card |
msg.content | string | Conditional | Required for text; optional for card. Card rendering and offline notification text are taken from msg.ext. |
msg.ext | object | Conditional | Required for card and must be a JSON object. A JSON string is rejected. |
Field constraints:
- Send
useridsas 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)
| Field | Type | Required | Description |
|---|---|---|---|
title | string | No | Card title, up to 64 characters |
content | string | No | Card body, up to 2,000 characters |
img | string | No | Card cover image URL; when non-empty, it must be an absolute HTTP/HTTPS URL |
button | object[] | No | Action button group, with up to 3 buttons |
button[].type | string | Yes | Button style: normal or primary |
button[].text | string | Yes | Button label |
button[].actionType | string | Yes | Button action type: url or none |
button[].action | string | Conditional | Button URL, required when actionType="url" |
Field constraints:
msg.ext.titleandmsg.ext.contentare counted as Unicode characters. A missing or explicitnullvalue is treated as unset.titleis limited to 64 characters andcontentto 2,000 characters.- If either field exceeds its limit, the entire batch request is rejected with
card title is too longorcard 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 titleorinvalid card content.
Offline notification copy:
- When the recipient is offline, the notification first uses
msg.ext.title. If the title is empty, it usesmsg.ext.content; if both are empty, it uses the fallback textYou 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": {}
}
| Field | Type | Description |
|---|---|---|
mtype | string | Message type: text or card |
content | string | Text content; required for mtype=text and unused for mtype=card |
ext | object | Structured 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."
}
| Constraint | Description |
|---|---|
content required | An empty value returns content is required for the entire batch. |
| Length | There 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
| Field | Type | Required | Description |
|---|---|---|---|
title | string | No | Card title; omitted from display when empty |
content | string | No | Card body; omitted from display when empty |
img | string | No | Card image URL; omitted from display when empty |
button | object[] | No | Card 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:
| Field | Limit | Error when exceeded |
|---|---|---|
title | 64 characters | card title is too long |
content | 2,000 characters | card content is too long |
| Case | Result |
|---|---|
| Each field is within its own limit | Accepted |
| A field exceeds its limit | Rejected with card title is too long or card content is too long |
| A field is a number, object, array, or other non-string value | Rejected 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:
| Order | Value | Description |
|---|---|---|
| 1 | ext.title | Used first when the trimmed title is non-empty |
| 2 | ext.content | Used when the title is empty |
| 3 | You have a new message | Fallback 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
| Field | Type | Description |
|---|---|---|
type | string | Button style: normal or primary |
text | string | Button label |
actionType | string | Button action type; only url is currently supported for navigation |
action | string | Action 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 count Layout 1 The button occupies the full row 2 Two buttons side by side on the first row 3 or more Two 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:
| Field | Validation condition |
|---|---|
ext.img | Validated whenever it is non-empty |
ext.button[].action | Validated only when that button's actionType is url |
| Value | Result |
|---|---|
Absolute HTTP/HTTPS URL, such as https://example.com/a | Accepted |
URL without a scheme, such as www.example.com | Rejected |
Custom schemes such as javascript:, file:, or data: | Rejected |
| Empty string | Accepted; 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, orbuttonfields 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
Search
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
| Item | Description |
|---|---|
| Idempotency key | Required Idempotency-Key request header |
| Scope | Unique per app and valid for 24 hours |
| Semantic validation | The server records a digest fingerprint of the request body |
| Same payload submitted again | Treated as a timeout or duplicate retry; the previous result is returned without producing a duplicate message |
| A different payload reuses the same key | The 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
| Field | Type | Description |
|---|---|---|
code | integer | Business status; 200 means the request was accepted for processing |
msg | string | Error message; empty on success |
data.requestId | string | Value corresponding to the X-Request-Id request header |
data.results | object[] | Item-level delivery results in the same order as userids |
data.results[].index | integer | Zero-based index in the request's userids list |
data.results[].userId | string | Target user ID |
data.results[].status | string | Delivery status: queued, duplicate, rejected, or failed |
data.results[].sessionId | string | Bot-to-human session ID; returned on success |
data.results[].messageId | string | Unique message ID (MID); returned on success |
data.results[].seq | string | Strictly increasing sequence number within the session; returned on success |
data.results[].errorCode | string | Error code when delivery is rejected or fails |
data.results[].errorMessage | string | Detailed error description when delivery is rejected or fails |
data.summary | object | Summary statistics |
data.summary.total | integer | Total number of target users |
data.summary.success | integer | Number entering the delivery queue; queued and duplicate count as success |
data.summary.failed | integer | Number 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 status | msg error code | Description and handling |
|---|---|---|
| 400 | INVALID_REQUEST | Content-Type is not application/json, or X-Request-Id is missing |
| 401 | INVALID_TOKEN | Token is missing, expired, invalid, or the app has been disabled or deleted |
| 403 | SCOPE_DENIED | Token lacks the robot.push permission |
| 403 | IP_DENIED | Request IP is not in the app's IP allowlist |
| 503 | AUTH_UNAVAILABLE | Authentication dependency is temporarily unavailable |
Request-level business errors (HTTP 200, code != 200)
msg error code | Description |
|---|---|
missing idempotency key | The request is missing the Idempotency-Key header |
invalid request | Required request data is missing, such as robotCode, userids, or msg.mtype |
content is required | content is empty for a text message |
card ext is invalid | Card ext is missing, not a valid JSON object, or supplied as a string |
card title is too long | Card ext.title exceeds 64 characters |
card content is too long | Card ext.content exceeds 2,000 characters |
invalid card title | Card ext.title is not a string, such as a number, object, or array |
invalid card content | Card ext.content is not a string, such as a number, object, or array |
unsupported message type | mtype is not text or card |
robot unavailable | Robot or app is disabled, unpublished, or in an abnormal state |
invalid user id at index N | The user ID at index N is not a valid positive integer string |
duplicate user id | The userids list contains a duplicate user ID |
IDEMPOTENCY_KEY_REUSED | The same key was bound to a different request body |
IDEMPOTENCY_UNAVAILABLE | Idempotency storage is unavailable; retry later |
Per-user error codes (results[].errorCode)
errorCode | status | Description |
|---|---|---|
USER_NOT_ALLOWED | rejected | Target user is outside the app's visibility scope or does not exist |
USER_UNAVAILABLE | rejected / failed | User is unavailable because the account is disabled or deleted |
SESSION_FAILED | failed | Creating or querying the bot-to-human session failed |
SESSION_TYPE_INVALID | failed | The session service returned a non-bot session type |
MESSAGE_FAILED | failed | Delivery 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.
Related endpoints
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 != 200means the entire request failed; do not treat it as a partial success.- Process
results[]item by item.rejectedusually indicates a permission or visibility issue; retryfailedonly 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-Keyand request body to avoid duplicate sends.