Event Subscription
This page is the complete protocol reference for event subscription: it covers configuration items, push requests, encryption/decryption and signatures, the event plaintext structure, receiver responses, retry and idempotency, and troubleshooting.
This page is the complete event-subscription protocol reference.
1. Scope
Currently, event notifications are supported for the creation, change, and deletion of users, departments, members, and member-department relationships.
What is delivered is the event itself, not a differential snapshot: a single business operation may produce multiple events (for example, adding a member produces both
contact.user.created and contact.membership.changed), and the app needs to process events one by one.
2. Subscribed Events
Event code (EventType) | Event name | Entity type | Trigger |
|---|---|---|---|
contact.user.created | User created | user | An administrator creates a member, or a member is created after a registration request is approved |
contact.user.updated | User information changed | user | The member profile (name, position, employee number, departments, and so on) changes; the member changes their own avatar |
contact.user.status.changed | User status changed | user | The member is enabled or disabled; the member is deleted |
contact.department.created | Department created | department | A department is created, including the first creation of the enterprise root department |
contact.department.updated | Department information changed | department | The department name, parent department, sort order, or visibility changes |
contact.department.deleted | Department deleted | department | A department is deleted, including batch deletion |
contact.membership.changed | Member-department relationship changed | user | A member joins or leaves a department; members are imported in bulk |
Two semantics that need special attention:
- There is no separate user-deletion event: when a member is deleted,
contact.user.status.changedis sent, withData.deletedset totrue; - Department deletion is likewise marked by
Data.deleted, and the event code is stillcontact.department.deleted.
3. Configuration Items
Configure these in the admin console under "Internal app → Event subscription → Subscription management":
| Configuration item | Description |
|---|---|
| Push method | Fixed to HTTP push; it cannot be chosen |
Encryption aes_key | 43 characters, upper- or lowercase letters or digits, i.e. the base64 encoding of a 32-byte AES key with the = padding removed |
Signature token | 3–32 characters, upper- or lowercase letters or digits |
| Request URL | The HTTP/HTTPS address that receives events; for the rules, see 3.1 |
| Event switches | All off by default; turn on the ones you need |
aes_key and token are decided by the app itself; the platform only validates their format. The "Reset" button on the page only generates new random values and
does not save them automatically; it takes effect only after you click save and publish a version. Credentials are displayed on the page in masked form.
3.1 Request URL Rules
- It must be an absolute address beginning with
http://orhttps://and carrying a host name; any other protocol is rejected; - Addresses carrying user information (such as
http://user:pass@host/path) are not accepted; - Under a private deployment, the platform instance must be able to reach that address; an intranet address reachable within the same enterprise network is equally usable, and a public domain name is not required;
- On save, only the address syntax is validated; the address is not actively probed; connectivity is determined by the first real event delivery, so please confirm for yourself that the address is reachable and the certificate is valid (if HTTPS);
- The request URL should not carry
signature,timestamp, ornoncequery parameters of its own (including the compatible spellingsmsg_signatureandtimeStamp): they are repopulated for each delivery, and any values you supply are overwritten.
4. Push Request
When an event occurs, the platform sends one HTTP POST to the configured request URL:
POST /your/callback?signature=...×tamp=...&nonce=...
Content-Type: application/json
Accept: application/json
{"encrypt":"..."}
| Item | Value |
|---|---|
| Method | POST |
| Protocol field location | The query string: signature, timestamp, nonce |
| Request body | A JSON object with only an encrypt field |
| Timeout | 2500ms; a timeout is treated as a delivery failure |
| Redirects | 3xx redirects are not followed; a 3xx is treated directly as a failure |
timestamp | A millisecond Unix timestamp string |
nonce | A 16-character random alphanumeric string |
The signature and the plaintext are separate: the signature is in the URL and the ciphertext is in the request body; plaintext never appears in the request body.
5. Encryption, Decryption, and Signature
The protocol is compatible with DingTalk encrypted callbacks; the algorithm is fixed, and the receiver must implement decryption and signature verification itself.
5.1 Keys and Credentials
| Credential | Purpose |
|---|---|
aes_key | A 43-character text. How to decode: first append one = to make a 44-character string, then perform standard base64 decoding to obtain a 32-byte AES key |
token | Participates in the signature computation |
| App AppId | This is the owner_key in the encrypted frame, used to confirm that the event is indeed sent to this app |
5.2 Decryption Steps
-
Take
signature,timestamp, andnoncefrom the URL andencryptfrom the request body, and verify the signature as described in 5.3; -
Base64-decode
encryptto obtain the ciphertext; -
Decrypt with the 32-byte AES key using AES-256-CBC; the IV is the first 16 bytes of the key;
-
Remove the PKCS#7 padding. Note that the padding block size is 32 bytes, not AES's 16 bytes—this is a convention specific to this protocol, and removing the padding by 16 bytes will fail;
-
Split the plaintext according to the frame structure below:
┌──────────────────┬────────────────────┬─────────────────┬─────────────────┐│ 16-byte random │ 4-byte plaintext │ Event plaintext │ owner_key ││ prefix │ length (big-endian)│ (JSON, UTF-8) │ (App AppId) │└──────────────────┴────────────────────┴─────────────────┴─────────────────┘ -
Use the length declared in the second segment to slice out the event plaintext; the remaining bytes are
owner_key; if it does not match this app's AppId, discard the message.
5.3 Signature Algorithm
1. Sort the four strings token, timestamp, nonce, and encrypt in lexicographic order (string comparison)
2. After sorting, concatenate them directly in order (no separator)
3. Take the SHA-1 digest of the concatenated result
4. Use the lowercase hexadecimal string as the signature
Compare the computed result with the signature in the URL; if they differ, reject the request.
The
encryptthat participates in the signature is the base64 text itself, not the decoded bytes.
5.4 Reference Implementation (Golang)
package callback
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/sha1"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"errors"
"sort"
)
// decodeAESKey decodes a 43-character aes_key into a 32-byte key: append "=" first, then decode with standard base64.
func decodeAESKey(aesKey string) ([]byte, error) {
return base64.StdEncoding.DecodeString(aesKey + "=")
}
// sign computes the protocol signature: sort the four values lexicographically, concatenate them, and take the lowercase SHA-1 hex digest.
func sign(token, timestamp, nonce, encrypted string) string {
values := []string{token, timestamp, nonce, encrypted}
sort.Strings(values)
sum := sha1.Sum([]byte(values[0] + values[1] + values[2] + values[3]))
return hex.EncodeToString(sum[:])
}
// Decrypt verifies the signature and extracts the event plaintext, returning the plaintext and the owner_key carried in the frame.
func Decrypt(token, aesKey, signature, timestamp, nonce, encrypted string) ([]byte, string, error) {
if sign(token, timestamp, nonce, encrypted) != signature {
return nil, "", errors.New("signature mismatch")
}
key, err := decodeAESKey(aesKey)
if err != nil {
return nil, "", err
}
ciphertext, err := base64.StdEncoding.DecodeString(encrypted)
if err != nil || len(ciphertext)%aes.BlockSize != 0 {
return nil, "", errors.New("invalid ciphertext")
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, "", err
}
// The IV is fixed as the first 16 bytes of the key
plain := make([]byte, len(ciphertext))
cipher.NewCBCDecrypter(block, key[:aes.BlockSize]).CryptBlocks(plain, ciphertext)
plain, err = unpad32(plain)
if err != nil {
return nil, "", err
}
if len(plain) < 20 {
return nil, "", errors.New("frame too short")
}
// Frame structure: 16-byte random prefix + 4-byte big-endian length + plaintext + owner_key
length := binary.BigEndian.Uint32(plain[16:20])
start := 20
end := start + int(length)
if end > len(plain) {
return nil, "", errors.New("payload length exceeds frame")
}
return plain[start:end], string(plain[end:]), nil
}
// unpad32 removes PKCS#7 padding in 32-byte blocks; the block size is a convention specific to this protocol, not AES's 16 bytes.
func unpad32(data []byte) ([]byte, error) {
const blockSize = 32
if len(data) == 0 || len(data)%blockSize != 0 {
return nil, errors.New("invalid padded length")
}
padding := int(data[len(data)-1])
if padding < 1 || padding > blockSize || padding > len(data) {
return nil, errors.New("invalid padding")
}
if !bytes.Equal(data[len(data)-padding:], bytes.Repeat([]byte{byte(padding)}, padding)) {
return nil, errors.New("invalid padding")
}
return data[:len(data)-padding], nil
}
6. Event Plaintext Structure
After decryption you get UTF-8 encoded JSON text:
{
"EventType": "contact.user.created",
"EventTime": 1757900000000,
"eventId": "6f1c0f4e-2a3b-4c5d-8e9f-0a1b2c3d4e5f",
"BizId": "6f1c0f4e-2a3b-4c5d-8e9f-0a1b2c3d4e5f",
"TimeStamp": 1757900000000,
"entityType": "user",
"entityId": "10001",
"Data": {
"userId": 10001,
"name": "Zhang San",
"position": "Product Manager",
"employeeNo": "E1001",
"status": 0,
"departmentIds": [2, 7],
"action": "create"
}
}
| Field | Type | Description |
|---|---|---|
EventType | string | Event code; for the values, see chapter 2 |
EventTime | int64 | Time the event occurred, a millisecond Unix timestamp |
eventId | string | Unique event ID, the basis for idempotency; see chapter 8 |
BizId | string | The same as eventId; a compatibility protocol field |
TimeStamp | int64 | The same as EventTime; a compatibility protocol field |
entityType | string | Entity type: user or department |
entityId | string | Entity ID, the user ID or department ID in string form |
Data | object | Event business data; the structure varies with EventType |
6.1 Missing Fields Are the Norm
The fields in Data are mostly optional: when a value is an empty string, false, or an empty array, it does not appear in the JSON, and the receiver
needs to handle it as a default value; do not rely on a field always being present.
Two exceptions need attention:
- The three keys
userId,status, anddepartmentIdsare always present; when unassigned, their value is0; departmentIdsmay benullwhen the member belongs to no department; when parsing, treat bothnulland a missing value as an empty array.
6.2 User Event Data
When EventType is contact.user.created, contact.user.updated, or contact.user.status.changed:
| Field | Type | Description |
|---|---|---|
userId | uint64 | User ID, the same as entityId, always sent |
name | string | Name |
avatar | string | Avatar URL |
position | string | Position |
employeeNo | string | Employee number |
status | uint8 | Account status: 0 normal, 1 disabled |
departmentIds | uint64[] | List of department IDs the member currently belongs to, sorted |
previousDepartmentIds | uint64[] | List of department IDs before the change; sent only for relationship-change events |
previousStatus | uint8 | Account status before the change; sent only for status-change events |
action | string | Business action; see the table below |
deleted | bool | true means the user has been deleted |
The receiver must parse on the assumption that fields may be missing: for example, contact.user.updated may be a complete profile change or merely
an avatar change, and the latter does not contain name, position, or employeeNo. When you need the complete profile, call
Get Member Details to fill it in, and do not overwrite your local
full data with event fields.
Values of action:
action | Events it appears in | Meaning |
|---|---|---|
create | contact.user.created | A member is created individually, or created after a registration review is approved |
import | contact.user.created | Created by bulk import |
update | contact.user.updated, contact.user.status.changed, contact.membership.changed | A regular update |
status | contact.user.status.changed | Status changed through an enable/disable operation |
delete | contact.user.status.changed, contact.membership.changed | A member is deleted |
add | contact.membership.changed | A member joins a department |
For status-change-only events (
action=status),departmentIdsis an empty array; do not interpret it as "the user no longer belongs to any department". When you need department information, rely oncontact.user.updatedorcontact.membership.changed.
6.3 Department Event Data
When EventType begins with contact.department.:
| Field | Type | Description |
|---|---|---|
departmentId | uint64 | Department ID, the same as entityId |
name | string | Department name |
parentId | uint64 | Parent department ID; the root department's parent department ID is 0 |
sort | uint32 | Sort value |
contactVisible | uint8 | Visibility: 0 visible to everyone, 1 visible only to this department and its sub-departments |
previousName | string | Department name before the change; sent only for update events |
previousParentId | uint64 | Parent department ID before the change; sent only for update events |
previousSort | uint32 | Sort value before the change; sent only for update events |
previousContactVisible | uint8 | Visibility before the change; sent only for update events |
deleted | bool | true means the department has been deleted |
6.4 Member-Department Relationship Event Data
When EventType is contact.membership.changed, entityType is user and entityId is the user ID:
| Field | Type | Description |
|---|---|---|
userId | uint64 | User ID |
departmentIds | uint64[] | List of department IDs after the change |
previousDepartmentIds | uint64[] | List of department IDs before the change |
status | uint8 | Account status |
action | string | add (joined), update (adjusted), delete (deleted along with the member), import (bulk import) |
Rely on the fields listed in this chapter; do not depend on keys that are not listed.
7. Receiver Response
The receiver must return HTTP 2xx within 2500ms, and the response body must be an encrypted envelope:
{
"msg_signature": "6f1c0f4e...",
"encrypt": "....",
"timeStamp": "1757900000123",
"nonce": "Ab3dE9fGh1JkLm2n"
}
| Field | Description |
|---|---|
encrypt | The base64 text obtained by encrypting the plaintext success (7 ASCII characters) with the same protocol |
msg_signature | The signature computed over the encrypt from the previous step and this response's timeStamp, nonce, and token; the algorithm is the same as 5.3 |
timeStamp | A millisecond timestamp generated by this response itself, with a field name whose first letter is an uppercase S; timestamp is also accepted |
nonce | A random string generated by this response itself |
The timeStamp and nonce in the response are generated by the receiver itself and do not need to echo the values from the request; the signature can simply be recomputed
with the values generated by the receiver.
Delivery counts as successful only when all of the following conditions are met:
- The HTTP status code is 2xx;
- The response body is valid JSON and contains all four fields
msg_signature,encrypt,timeStamp, andnonce; - Signature verification passes;
- The decrypted plaintext is exactly
success.
The response body limit is 64 KiB; exceeding it is judged a failure. Returning the plaintext success (unencrypted) does not count as success.
8. Retry and Idempotency
Events are committed in the same transaction as the business write, and are then expanded and called back by the platform's delivery task.
8.1 Retry
When delivery fails (timeout, non-2xx, a response body that does not meet the requirements of chapter 7, or a network error), the platform retries automatically:
| Item | Value |
|---|---|
| Backoff | Starts at 2 seconds and doubles each time |
| Backoff ceiling | 1 minute |
| Maximum attempts | 12 |
| Termination | Once the limit is reached, the event enters a terminal failed state and is no longer delivered |
The platform does not guarantee that events will eventually be delivered. For important business, use app-side active polling of the Contacts APIs (see List Departments as a fallback.
8.2 Idempotency
Under retries and concurrency, the receiver may still receive duplicate events, so it must be idempotent by eventId:
- Store or cache with
eventIdas the unique key, and return success directly for duplicate events; - The idempotency window is recommended to be no shorter than 24 hours;
- Platform-side deduplication records are kept for 7 days and failed records for 30 days; afterwards there is no longer a platform-side basis for deduplication, so do not rely on platform-side records for long-term deduplication;
- A single business operation produces multiple events with different
eventIdvalues (for example, creating a member producescontact.user.createdandcontact.membership.changedat the same time); they are independent events and cannot be deduplicated against each other.
8.3 Ordering
The delivery chain does not guarantee the order in which events arrive; multiple changes to the same entity may also arrive out of order or overlapping, and retries can even cause an earlier event to
be delivered later than a newer one. The receiver should merge state using EventTime and eventId, and must not directly
overwrite local data in arrival order.
9. Implementation Recommendations
- Persist before processing: persist the raw plaintext immediately after receiving an event, then consume it asynchronously, so that a business-processing failure does not lose the event;
- Respond quickly: move time-consuming logic into asynchronous tasks to ensure a valid encrypted response is returned within 2500ms;
- Idempotency table: use a unique index on
eventIdas a fallback, and return success directly for duplicate events; - Version merging: keep
EventTimeperentityIdand accept only newer timestamps, to avoid out-of-order overwrites; - Fill in missing fields: when event fields are missing, call the Contacts APIs to fill in the complete profile; do not overwrite everything with partial fields;
- Scheduled reconciliation: a full reconciliation daily or weekly is recommended, to compensate for lost events and ordering issues.
10. Troubleshooting
| Symptom | What to check |
|---|---|
| The configuration saves successfully but no events arrive | Has a version been published? Is the app enabled? Is the corresponding event switch turned on? Is the triggered event within the seven phase-one items? |
| No requests arrive at all | Can the platform instance reach that address (an intranet address must be reachable from the same network)? Are the address protocol and host name valid? Is the endpoint blocked by a firewall or authentication? |
| Signature verification fails | Is token the value from the currently published version? Is the encrypt participating in the signature the base64 text? Is the sorting lexicographic? |
| Decryption fails or the padding is invalid | Is aes_key the value from the currently published version? Was = appended before decoding? Is the padding-removal block size 32? |
| Decryption succeeds but JSON parsing fails | The plaintext is UTF-8 JSON, which must be parsed according to the Data structure; note that fields may be missing |
It reports an owner_key mismatch | Does the AppId at the end of the frame match this app? Confirm that the event was not sent to another app |
| It keeps being retried | Was the response returned within 2500ms? Was 2xx returned? Is the response body an encrypted success? Does the response body exceed 64 KiB? |
| The same event is received repeatedly | This is normal; handle it idempotently by eventId |
| The event order is scrambled | This is normal; merge by EventTime and do not overwrite in arrival order |
About
check_url: the current version does not actively send URL verification events. The request URL is not probed when the configuration is saved, and any request whoseEventTypeischeck_urlcan simply be ignored.
11. Related
- Authentication and signing: app authentication and access-token acquisition.
- List Departments: active-query fallback when an event is missed.
- Message types and cards: robot message
mtype,content, and card structure.
Event callbacks do not use the unified Open API response envelope; use List Departments for complete data reconciliation.