Customer churn alerts: WeCom → Feishu
Catch a WeCom customer-deletion event and flag the churn risk on a Feishu sales board.
WeCom fires an event when a customer deletes your sales rep, but there's no native alert for it and Feishu can't receive an inbound webhook — you need an HTTP endpoint that verifies WeCom's signed callback, decrypts it, and writes the churn to a Feishu sales board, @-ing the rep. WeCom locks employee email/mobile on self-built apps made after mid-2022, so UserID → open_id mapping is a one-time setup-time table. The alert relies on the callback's own fields, since the customer may not stay reachable if this was their only rep.
How it flows
- 01
WeCom posts the churn event
A customer removes your sales rep from their WeCom contacts; the platform POSTs the change_external_contact event (ChangeType=del_follow_user) as encrypted XML to your receiving URL, with msg_signature/timestamp/nonce in the query string.
- 02
Verify the signature and decrypt the payload
sha1(sorted[Token, timestamp, nonce, encrypted body]) must match msg_signature; then AES-CBC decrypt with EncodingAESKey to recover the plain XML. WeCom's callback and API responses return HTTP 200 with a JSON errcode even on failure, so status code alone can't tell you this step succeeded.
- 03
Filter for the right event
Confirm Event=change_external_contact and ChangeType=del_follow_user — not the similarly-named del_external_contact, which fires the opposite way, when your rep deletes the customer. del_follow_user carries no Source field; just read UserID (the rep), ExternalUserID (the customer), and CreateTime out of the decrypted XML.
- 04
Look up the rep's Feishu open_id
Match UserID against the static mapping table from setup to get the rep's Feishu open_id and name — there's no live API path for this, since WeCom locks email/mobile on post-2022 self-built apps.
- 05
Best-effort: fetch the customer's name
Try externalcontact/get with the ExternalUserID for a display name. The customer is usually still active elsewhere in the business under this event, so the call typically succeeds — treat it as best-effort only for the edge case where this rep was the customer's sole contact and they've since dropped out of any tracked scope; fall back to the raw ExternalUserID from the callback if it fails.
- 06
Write the churn-risk record
Get a Feishu tenant_access_token (POST to tenant_access_token/internal with app_id/app_secret; cache it and refresh once under 30 minutes remain, since Feishu returns a fresh token in that same response inside the window), then POST the customer id, rep, and event time (as a millisecond epoch) into the sales-board Bitable via bitable/v1/.../records — this row, not a re-fetched customer profile, is what the workflow treats as the record of the event.
- 07
Notify the rep
POST to im/v1/messages with an `<at id="{open_id}">` mention in the text, either DMed to the rep or posted to the team channel — this only raises the flag; the rep decides what to do about the customer.
Set up each app
Work through these in order — later apps usually need a token or an id from an earlier one.
WeCom
Trigger — fires when a customer deletes your sales rep
- 01
Create a dedicated self-built app
WeCom admin console (work.weixin.qq.com) → 应用管理 (App Management) → 自建应用 (Custom Apps) → create an app just for this alert — don't reuse another app's Secret. (Menu labels here are the Chinese console; WeCom's admin UI follows your account's language setting, so switch it if yours is already in English.) Its app page shows the Secret; the corpid is on your company's 我的企业 (My Company) page. Together they exchange for an access_token — sanity-check the pair once with the call below. WeCom's API answers HTTP 200 either way, so a real `errcode: 0` is what confirms it, not the status code.
GET cgi-bin/gettoken (confirm corpid/corpsecret work)curl -s -G "https://qyapi.weixin.qq.com/cgi-bin/gettoken" \ -d "corpid=$WECOM_CORPID" \ -d "corpsecret=$WECOM_APP_SECRET" # {"errcode":0,"access_token":"...","expires_in":7200} on success - 02
Configure the receiving message server URL, then allowlist your IP
Same app → 开发者接口 (Developer API) → 设置API接收/接收消息 (Configure API Receiving). Enter your endpoint's public HTTPS URL, a Token you choose, and click to auto-generate a 43-character EncodingAESKey. Saving fires an immediate GET handshake at that URL with echostr in the query string — urldecode it first (WeCom's docs flag this explicitly; skip it and verification fails silently on the `+`/`=` characters base64 puts in there), then your endpoint has 1 second to answer with the decrypted plaintext or the save fails. Once live, the real churn-event POST gets a longer 5-second window before WeCom disconnects and retries, up to three times — don't conflate the two timers. Separately, on the same 开发者接口 (Developer API) page, open 企业可信IP (Trusted IP) and add your endpoint's outbound public IP — WeCom's docs don't spell out exactly what breaks without it, so treat this as a pre-launch check: call gettoken once from an un-allowlisted IP, confirm you get an IP-related error, then add the IP and re-test. This callback-URL check is a separate mechanism from WeCom's 'trusted domain' setting, which only gates the JS-SDK and web OAuth login and requires ICP filing.
What your endpoint does for the GET handshake1. Read msg_signature, timestamp, nonce, echostr from the query string; urldecode echostr before using it 2. sha1(sorted([Token, timestamp, nonce, echostr])) must equal msg_signature 3. base64-decode (EncodingAESKey + "="); AES-CBC decrypt echostr using the first 16 bytes of that decoded key as the IV; strip PKCS7 padding 4. Return the decrypted plaintext as the raw response body — no quotes, no BOM - 03
Register the app in Customer Contact and turn on the callback
客户联系 (Customer Contact) → 配置 (Configure) → add this app under 可调用接口的应用 (apps allowed to call the API) — this is a different screen from the app's own 权限 (Permissions) tab, and it's what actually grants externalcontact API access for a self-built app. On that same screen, turn on 接收事件开关:外部联系人变更回调 (the external-contact-change callback toggle) — miss this and the URL handshake still passes, gettoken still works, and you will never receive a single event, with nothing in any log to explain why. Then add every sales rep you want tracked to 使用范围 (usage scope) — that list is also where you'll read off UserIDs for the mapping table below.
- 04
Build the WeCom-UserID → Feishu-open_id mapping table
WeCom only returns a member's email or mobile from user/get on self-built apps created before 2022-06-20; on anything newer, reading either now needs an admin grant plus that employee's own OAuth consent — too much friction for a hands-off alert. Skip that path: since your reps' UserIDs are already visible in the usage-scope list from the previous step, have someone build a one-time table mapping each WeCom UserID to the matching Feishu open_id (the Feishu setup below shows how to get those) and store it wherever your receiver reads config from. This is a manual, one-time admin step — the workflow does not resolve it live.
Feishu
Carries the churn-risk record and notifies the rep
- 01
Create the self-built app with three scopes
open.feishu.cn → 开发者后台 (Developer Console) → create a 企业自建应用 (custom enterprise app); note the App ID and App Secret. (Same caveat as the WeCom side: this is the Chinese console — switch your account's language if yours is already English.) Under 权限管理 (Permissions), add exactly three: `bitable:app` (read/write Bitable records), `im:message:send_as_bot` (send messages), and `contact:user.id:readonly` (used once, for the mapping step below — not needed per event). Publish a version, then have a tenant admin approve the app in 管理后台 (Admin Console) → 应用管理 (App Management) → this app → 权限管理 (Permissions): a self-built app's requested scopes sit inactive until an admin approves them there.
- 02
Point the app at the sales board
Open the target sales-board Bitable in your browser: the `feishu.cn/base/<app_token>` segment of the URL is the app_token, and the sheet tab's `table` query parameter is the table_id. A freshly created app has no access to any base until you add it — open the base's share/more menu and add this app as a collaborator, the same one-time grant Notion integrations need. Make sure the table has fields for a customer id, the rep, an event time, and a status — add any that are missing before the first write. Date fields take a millisecond timestamp and single-select fields take the option's exact text, as in the payload shape below.
POST bitable/v1/apps/{app_token}/tables/{table_id}/records — payload shape{ "fields": { "Customer ID": "wmxxxxxxxxxxxxxxxxxx", "Rep": "zhangsan", "Churn Time": 1770123456000, "Status": "Churn risk" } } - 03
Resolve each rep's email to a Feishu open_id
This one call needs a tenant_access_token first: trade the App ID/App Secret from step 1 for one via a POST to auth/v3/tenant_access_token/internal (body `{app_id, app_secret}`) — the same exchange the workflow itself repeats per event, just done once here by hand. With that token, `batch_get_id` turns each rep's corporate email into the Feishu open_id the mapping table needs — add the returned ids as that table's Feishu-side column. This is the only place `contact:user.id:readonly` gets used; the workflow doesn't call it per event.
POST /open-apis/contact/v3/users/batch_get_idcurl -s -X POST "https://open.feishu.cn/open-apis/contact/v3/users/batch_get_id" \ -H "Authorization: Bearer $FEISHU_TENANT_TOKEN" \ -H "Content-Type: application/json" \ -d '{"emails": ["rep1@company.com", "rep2@company.com"], "include_resigned": false}' - 04
(Optional) Grab a custom bot webhook as a backup channel
In the target notification group chat, add a custom bot to get a webhook URL you can POST to directly if the tenant_access_token path is ever down. The Bitable row stays the record of truth either way — this is only a fallback for the notification, not the churn record itself.