Skip to main content

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 nameEntity typeTrigger
contact.user.createdUser createduserAn administrator creates a member, or a member is created after a registration request is approved
contact.user.updatedUser information changeduserThe member profile (name, position, employee number, departments, and so on) changes; the member changes their own avatar
contact.user.status.changedUser status changeduserThe member is enabled or disabled; the member is deleted
contact.department.createdDepartment createddepartmentA department is created, including the first creation of the enterprise root department
contact.department.updatedDepartment information changeddepartmentThe department name, parent department, sort order, or visibility changes
contact.department.deletedDepartment deleteddepartmentA department is deleted, including batch deletion
contact.membership.changedMember-department relationship changeduserA member joins or leaves a department; members are imported in bulk

Two semantics that need special attention:

  1. There is no separate user-deletion event: when a member is deleted, contact.user.status.changed is sent, with Data.deleted set to true;
  2. Department deletion is likewise marked by Data.deleted, and the event code is still contact.department.deleted.

3. Configuration Items​

Configure these in the admin console under "Internal app → Event subscription → Subscription management":

Configuration itemDescription
Push methodFixed to HTTP push; it cannot be chosen
Encryption aes_key43 characters, upper- or lowercase letters or digits, i.e. the base64 encoding of a 32-byte AES key with the = padding removed
Signature token3–32 characters, upper- or lowercase letters or digits
Request URLThe HTTP/HTTPS address that receives events; for the rules, see 3.1
Event switchesAll 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​

  1. It must be an absolute address beginning with http:// or https:// and carrying a host name; any other protocol is rejected;
  2. Addresses carrying user information (such as http://user:pass@host/path) are not accepted;
  3. 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;
  4. 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);
  5. The request URL should not carry signature, timestamp, or nonce query parameters of its own (including the compatible spellings msg_signature and timeStamp): 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=...&timestamp=...&nonce=...
Content-Type: application/json
Accept: application/json

{"encrypt":"..."}
ItemValue
MethodPOST
Protocol field locationThe query string: signature, timestamp, nonce
Request bodyA JSON object with only an encrypt field
Timeout2500ms; a timeout is treated as a delivery failure
Redirects3xx redirects are not followed; a 3xx is treated directly as a failure
timestampA millisecond Unix timestamp string
nonceA 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​

CredentialPurpose
aes_keyA 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
tokenParticipates in the signature computation
App AppIdThis is the owner_key in the encrypted frame, used to confirm that the event is indeed sent to this app

5.2 Decryption Steps​

  1. Take signature, timestamp, and nonce from the URL and encrypt from the request body, and verify the signature as described in 5.3;

  2. Base64-decode encrypt to obtain the ciphertext;

  3. Decrypt with the 32-byte AES key using AES-256-CBC; the IV is the first 16 bytes of the key;

  4. 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;

  5. 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) │
    └──────────────────┴────────────────────┴─────────────────┴─────────────────┘
  6. 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 encrypt that 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"
}
}
FieldTypeDescription
EventTypestringEvent code; for the values, see chapter 2
EventTimeint64Time the event occurred, a millisecond Unix timestamp
eventIdstringUnique event ID, the basis for idempotency; see chapter 8
BizIdstringThe same as eventId; a compatibility protocol field
TimeStampint64The same as EventTime; a compatibility protocol field
entityTypestringEntity type: user or department
entityIdstringEntity ID, the user ID or department ID in string form
DataobjectEvent 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:

  1. The three keys userId, status, and departmentIds are always present; when unassigned, their value is 0;
  2. departmentIds may be null when the member belongs to no department; when parsing, treat both null and 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:

FieldTypeDescription
userIduint64User ID, the same as entityId, always sent
namestringName
avatarstringAvatar URL
positionstringPosition
employeeNostringEmployee number
statusuint8Account status: 0 normal, 1 disabled
departmentIdsuint64[]List of department IDs the member currently belongs to, sorted
previousDepartmentIdsuint64[]List of department IDs before the change; sent only for relationship-change events
previousStatusuint8Account status before the change; sent only for status-change events
actionstringBusiness action; see the table below
deletedbooltrue 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:

actionEvents it appears inMeaning
createcontact.user.createdA member is created individually, or created after a registration review is approved
importcontact.user.createdCreated by bulk import
updatecontact.user.updated, contact.user.status.changed, contact.membership.changedA regular update
statuscontact.user.status.changedStatus changed through an enable/disable operation
deletecontact.user.status.changed, contact.membership.changedA member is deleted
addcontact.membership.changedA member joins a department

For status-change-only events (action=status), departmentIds is an empty array; do not interpret it as "the user no longer belongs to any department". When you need department information, rely on contact.user.updated or contact.membership.changed.

6.3 Department Event Data​

When EventType begins with contact.department.:

FieldTypeDescription
departmentIduint64Department ID, the same as entityId
namestringDepartment name
parentIduint64Parent department ID; the root department's parent department ID is 0
sortuint32Sort value
contactVisibleuint8Visibility: 0 visible to everyone, 1 visible only to this department and its sub-departments
previousNamestringDepartment name before the change; sent only for update events
previousParentIduint64Parent department ID before the change; sent only for update events
previousSortuint32Sort value before the change; sent only for update events
previousContactVisibleuint8Visibility before the change; sent only for update events
deletedbooltrue 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:

FieldTypeDescription
userIduint64User ID
departmentIdsuint64[]List of department IDs after the change
previousDepartmentIdsuint64[]List of department IDs before the change
statusuint8Account status
actionstringadd (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"
}
FieldDescription
encryptThe base64 text obtained by encrypting the plaintext success (7 ASCII characters) with the same protocol
msg_signatureThe 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
timeStampA millisecond timestamp generated by this response itself, with a field name whose first letter is an uppercase S; timestamp is also accepted
nonceA 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:

  1. The HTTP status code is 2xx;
  2. The response body is valid JSON and contains all four fields msg_signature, encrypt, timeStamp, and nonce;
  3. Signature verification passes;
  4. 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:

ItemValue
BackoffStarts at 2 seconds and doubles each time
Backoff ceiling1 minute
Maximum attempts12
TerminationOnce 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:

  1. Store or cache with eventId as the unique key, and return success directly for duplicate events;
  2. The idempotency window is recommended to be no shorter than 24 hours;
  3. 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;
  4. A single business operation produces multiple events with different eventId values (for example, creating a member produces contact.user.created and contact.membership.changed at 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​

  1. 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;
  2. Respond quickly: move time-consuming logic into asynchronous tasks to ensure a valid encrypted response is returned within 2500ms;
  3. Idempotency table: use a unique index on eventId as a fallback, and return success directly for duplicate events;
  4. Version merging: keep EventTime per entityId and accept only newer timestamps, to avoid out-of-order overwrites;
  5. 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;
  6. Scheduled reconciliation: a full reconciliation daily or weekly is recommended, to compensate for lost events and ordering issues.

10. Troubleshooting​

SymptomWhat to check
The configuration saves successfully but no events arriveHas 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 allCan 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 failsIs 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 invalidIs 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 failsThe 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 mismatchDoes the AppId at the end of the frame match this app? Confirm that the event was not sent to another app
It keeps being retriedWas 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 repeatedlyThis is normal; handle it idempotently by eventId
The event order is scrambledThis 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 whose EventType is check_url can simply be ignored.

Event callbacks do not use the unified Open API response envelope; use List Departments for complete data reconciliation.