WorkflowsMCP

Customer churn alerts: WeCom → Feishu

Catch a WeCom customer-deletion event and flag the churn risk on a Feishu sales board.

@workflowsmcpVerified crmsaleschurn-riskcn-ecosystemcustomer-success

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

  1. 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.

  2. 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.

  3. 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.

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

  5. 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.

  6. 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.

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

  1. 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
  2. 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 handshake
    1. 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
  3. 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.

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

  1. 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.

  2. 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"
      }
    }
  3. 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_id
    curl -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}'
  4. 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.