HTTP API
Everything the CLI and the OpenClaw plugin do goes through this API, so another agent framework, a bot or an app can do the same. The daemon serves JSON at http://127.0.0.1:6174/v1. Every request except GET /health needs a bearer token; create one with wx tokens create.
A typical integration follows the event stream for new messages, fetches each one, and replies through /sends.
export WX=http://127.0.0.1:6174/v1 TOKEN=... # and ACCOUNT=<id from /v1/accounts>curl -H "Authorization: Bearer $TOKEN" $WX/accountsFor calls, use the separate Calls API reference for WebSocket commands, events and payload examples, and the media reference for PCM framing.
Profiles
Use the existing daemon connection key for profile management. There is no separate frontend role to configure.
| Action | Request |
|---|---|
| List profiles | GET /accounts |
| Add a profile | POST /accounts with { "alias": "Personal" } |
| Sign in | POST /accounts/{id}/login-attempts with an Idempotency-Key |
| Sign out | POST /accounts/{id}/auth/logout with an Idempotency-Key |
| Remove a signed-out profile | DELETE /accounts/{id} with { "confirmAccountId": "<same id>" } |
Removal returns { "accountId": "…", "removed": true, "filesRetained": true }.
It unregisters the profile and keeps its local files and recovery records. Sign out
and wait for active operations to finish first; otherwise the response is 409.
Removed IDs and profile paths stay reserved. The existing token scopes apply:
creation, renaming and removal use admin; sign-in and sign-out use login.
Read
curl -H "Authorization: Bearer $TOKEN" "$WX/accounts/$ACCOUNT/threads?limit=20"curl -H "Authorization: Bearer $TOKEN" "$WX/accounts/$ACCOUNT/threads/filehelper/messages?limit=50"curl -H "Authorization: Bearer $TOKEN" "$WX/accounts/$ACCOUNT/messages?query=invoice"Lists return { "items": [...], "nextCursor": "..." }. Pass cursor= with the same query to get the next page.
GET /threads and GET /threads/{id} include sidebar summaries from saved history:
| Field | Contents |
|---|---|
muted | Saved notification preference for a direct chat or group: true, false, or null when missing or unresolved. |
pinned | Saved pin state for an ordinary direct chat or active group: true, false, or null when unavailable. |
lastMessage | Latest local message ID, sender ID/name, direction, type, time and a plain-text text preview of up to 160 characters. null means empty local history; omitted means the preview is unavailable. |
avatarMembers | Up to four cached group members, each with id and displayName; empty for direct chats or an unavailable roster. Fetch cached pictures through GET /avatars/{username}. |
These reads do not refresh WeChat or download media. Media messages use labels such as “Photo” and “Sticker”; recalled content stays suppressed. Refresh the thread list after message, contact or group events to update the summaries. Contact refresh and sync update saved metadata. Notification consumers should require account status contactSyncReady: true and thread muted: false before alerting. Notification edits through PUT /threads/{id}/notifications support ordinary direct contacts and groups. A direct edit reads the current contact before submitting its change. Repeat the same idempotency key to recover an operation; an uncertain outcome must not be resubmitted with a new key.
Pin or unpin with PUT /threads/{id}/pin, body {"pinned":true} or {"pinned":false}, an admin token and Idempotency-Key. The SDK method is setThreadPinned(accountId, threadId, pinned, key). The response is an operation; inspect its remoteOutcome and profileProjection. Saved pin changes emit thread.updated; refetch the thread or list. A new list places pinned chats first, then orders each section by saved activity. An existing pagination cursor keeps its original order.
Contacts
List, search and read contacts saved in one account’s WeChat profile. All routes
below start with /v1/accounts/{account} and require read scope for that account.
Choose ACCOUNT from GET /v1/accounts; it is the daemon’s account ID, not a
contact’s WeChat username. Percent-encode usernames in URL paths.
| Task | Request | TypeScript SDK |
|---|---|---|
| List or search saved contacts | GET /contacts?search=Alice&limit=20 | contacts(accountId, { query: "Alice", limit: 20 }) |
| Read one saved contact | GET /contacts/{username} | contact(accountId, username) |
| Refresh one known identity | POST /contacts/{username}/refresh with {} | refreshContact(accountId, username) |
| Fetch the contact inventory | POST /contact-sync with {} | syncContacts(accountId) |
| Look up known identities on WeChat | POST /contacts/lookup with {"ids":["wxid_alice"]} | No dedicated method |
Read saved contacts
-
Search by a case-insensitive substring of the ID, display name, nickname, remark or alias.
queryis an alias forsearch; use one, not both.Terminal window curl -G -H "Authorization: Bearer $TOKEN" \--data-urlencode 'search=Alice' --data-urlencode 'limit=20' \"$WX/accounts/$ACCOUNT/contacts" > contacts-page.jsonAn abridged, synthetic response:
{"items": [{"id": "wxid_alice", "username": "wxid_alice","displayName": "Alice", "nickname": "Alice Chen","remark": "Alice", "alias": null, "relation": "unknown","classification": {"category": "unknown", "namespace": "wechat","businessType": null, "businessKind": null, "openIm": null}}],"nextCursor": "OPAQUE_CURSOR","sourceGeneration": "PROFILE_GENERATION","asOfEventCursor": "EVENT_CURSOR"} -
If
nextCursoris non-null, pass it with the same search. Repeat until null:Terminal window CURSOR=$(jq -r '.nextCursor' contacts-page.json)curl -G -H "Authorization: Bearer $TOKEN" \--data-urlencode 'search=Alice' --data-urlencode "cursor=$CURSOR" \--data-urlencode 'limit=20' "$WX/accounts/$ACCOUNT/contacts" > contacts-page.jsonThe default limit is 50; accepted limits are 1–200. Search accepts at most 256 UTF-8 bytes. Cursors expire after 30 minutes and retain the first page’s IDs and order; fields reflect current saved data. Restart without a cursor after
409 pagination_cursor_expired. A removed row can become an{"id":"wxid_alice","deleted":true,"version":"..."}item. -
For a known username, use
GET /contacts/wxid_alice. A missing saved contact returns404 not_found; this does not establish friendship or server absence. CLI equivalents arewx --json contacts list --limit 20,wx --json contacts find Aliceandwx --json contacts get wxid_alice.
id and username identify the same peer. displayName prefers a nonempty
remark, then nickname, then username. nickname, remark and alias may be
null. relation currently remains unknown, including for saved friends.
Contacts exclude ordinary group records; use groups and member reads
for rosters. This API does not add friends, delete contacts or edit their remarks.
Refresh and sync contacts
curl -X POST -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' -d '{}' \ "$WX/accounts/$ACCOUNT/contacts/wxid_alice/refresh"Refresh and inventory requests return HTTP 202 with an operation (abridged):
{"id":"OPERATION_ID","kind":"metadata","state":"queued"}.
Poll GET /operations/{id} or client.operation(accountId, op.id), then reread
contacts. A finished operation can still have result.profileProjection set to
"partial"; inspect the result rather than relying on its state alone.
A refresh retrieves one known identity and saves supported fields. Contact sync
fetches the inventory in up to 10 pages or two minutes per operation. Inspect
complete, catalogComplete, failedItems, unavailableCount and
resumeAvailable in its result; call it again to continue saved progress.
Unavailable contacts do not imply deletion. CLI equivalents are
wx contacts refresh wxid_alice and wx contacts sync.
This inventory request is separate from the daemon’s automatic contact-change
stream. That stream emits contacts.updated after supported changes are saved
and contacts.sync for progress. Refetch affected resources after events.
Account status contactSyncReady means that stream caught up in this connection;
it does not promise a complete contact inventory.
POST /contacts/lookup is a different, immediate server lookup: it accepts one
or two distinct known IDs, returns items plus numeric per-ID results, and
reports profileProjection: "pending" on returned items. It does not save those
contacts. Prefer refresh when you need subsequent cached reads to change.
Interpret peer classification
Contact, thread, group and member reads derive classification from the same
saved profile. It is independent of thread kind and contact relation.
category | Saved evidence |
|---|---|
business | Official/business account metadata. |
openim / openim_customer_service | WeCom contact / separate customer-service identity. |
group / system | Recognized group / built-in peer such as File Transfer. |
unknown | No established category; never assume a personal account or friend. |
namespace distinguishes identifier families; it grants no additional actions.
businessType retains the numeric value; businessKind is official_account,
service_account, enterprise, enterprise_child, photo_account, or null
for unknown/conflicting values. An Official Account label does not establish a
subscription account. openIm contains cached appId, wordingId, localized
wordings (languageId, text) and application/account-type references; choose
the language in your app.
Send
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -H "Idempotency-Key: order-1234-confirmation" \ -d '{"threadId": "filehelper", "text": "Order 1234 confirmed"}' \ "$WX/accounts/$ACCOUNT/sends"Every send needs an Idempotency-Key. Sending the same key again returns the original send instead of sending twice. Check progress with GET /sends/{id}, or find a send by key with GET /sends/by-idempotency-key?key=....
Group text accepts mentions: { userIds: ["MEMBER_ID"] } or mentions: { all: true }.
See sending and receiving mentions for CLI, SDK and response examples.
To quote a saved message, send both its id and serverId from that chat’s message list:
{ "kind": "text", "threadId": "filehelper", "text": "Agreed", "quote": { "messageId": "MESSAGE_ID", "serverId": "SERVER_ID" }}The token needs read and send. The daemon reads the original from the same chat; callers supply only its IDs. In the TypeScript SDK:
await client.sendText(accountId, threadId, "Agreed", key, { messageId, serverId });To mention members in a quoted group reply, include both options:
await client.sendText(accountId, groupId, "Agreed", key, { quote: { messageId, serverId }, mentions: { userIds: [memberId] },});The HTTP request likewise accepts quote and mentions together for group text.
See quoted replies in the CLI guide for supported originals and limits.
For files, images, video and voice, stage bytes with POST /uploads and send the returned source ID. Send attachments covers the request, SDK methods, per-kind capability limits and longer job polling.
Receive
curl -N -H "Authorization: Bearer $TOKEN" "$WX/accounts/$ACCOUNT/events/stream"A server-sent event stream. A new message arrives as a message.committed event; fetch it with GET /messages/{messageId}. To resume after a disconnect, reconnect with ?after=CURSOR (or the standard Last-Event-ID header) and the daemon replays what you missed. POST /consumers/{name}/ack with {"cursor": "..."} records how far you’ve processed.
The stream sends an immediate heartbeat comment, followed by periodic keepalives. Heartbeat comments contain no message and do not advance the event cursor.
Recognized group changes emit group.updated after the saved profile changes; fetch GET /groups/{groupId} or its members. group.refresh_blocked means a follow-up read could not finish. These events share the same replayable stream. Automatic delivery of every edit made on another device is not established; see group refresh limits.
Routes
All account routes start with /v1/accounts/{account}.
| Area | Routes |
|---|---|
| Accounts | GET /v1/accounts, GET /, GET /capabilities, POST /connect, POST /disconnect, POST /logout |
| Login | POST /login-attempts, GET /login-attempts/{id}, GET /login-attempts/{id}/qr, POST /login-attempts/{id}/cancel |
| Chats and messages | GET /threads, GET /threads/{id}/messages, GET /messages?query=, GET /messages/{id} |
| Contacts | GET /contacts, GET /contacts/{username}, POST /contacts/{username}/refresh, POST /contact-sync, POST /contacts/lookup |
| Groups | GET /groups, GET /groups/{id}, GET /groups/{id}/members, GET /groups/{id}/members/{username} |
| Group edits and chat settings (admin) | POST /groups, PATCH /groups/{id}, POST /groups/{id}/leave, POST/DELETE /groups/{id}/members, PUT /groups/{id}/my-name, DELETE /groups/{id}/announcement, PUT /threads/{id}/notifications, PUT /threads/{id}/pin |
| Sending | POST /uploads, POST /sends, GET /sends/{id}, POST /sends/{id}/cancel, POST /messages/{id}/recall |
| Media | GET /messages/{id}/media, POST /media/lookup, POST /messages/{id}/media-transfers, GET /media/{id}/content |
| Read state | POST /threads/{id}/read-state with {"expectedVersion": "..."} |
| Background work | GET /jobs/{id}, GET /operations/{id} |
| Events | GET /events, GET /events/stream, POST /consumers/{name}/ack |
| Tokens | GET /v1/tokens, POST /v1/tokens, DELETE /v1/tokens/{id} |
Request and response types are generated from the Rust source into native.generated.ts. The wechat-api crate is the source of truth.
To read attachment metadata together, send {"messageIds":["MESSAGE_ID_1","MESSAGE_ID_2"]}
to POST /media/lookup with the media scope. A batch accepts up to 40 message IDs
and returns an items array with each messageId, its assets and an error
code or null. It does not download attachments. In the SDK, use
client.mediaAssetsBatch(accountId, messageIds).