WorkflowsMCP

Churn-risk account flag: Stripe → Intercom → Slack → Linear

Cancel or downgrade plus Intercom chatter → tiered Slack alert and a Linear retention task.

@workflowsmcpVerified finance-opschurncustomer-successrevenue

Stripe Workflows can already route a subscription event to different Slack channels by plan. They cannot fold Intercom conversation volume into that decision. This workflow fires when cancel_at_period_end flips to true or items downgrade—subscription.deleted is only a late fallback—and deliberately ignores invoice.payment_failed (Smart Retries already cover failed charges). It scores recent Intercom reopens, reads plan_tier from Price metadata, and only then posts a tiered Slack alert and opens a Linear retention issue.

How it flows

  1. 01

    Stripe webhook: scheduled cancel or downgrade

    customer.subscription.updated with cancel_at_period_end flipping to true (or items showing a cheaper price) enters the pipeline. customer.subscription.deleted is listened to in parallel as a late fallback — including accounts that skip the cancel_at_period_end path (for example dunning that cancels after retries) and only appear when the subscription actually ends. invoice.payment_failed is never a trigger.

  2. 02

    Match Intercom contact; read plan_tier from Price metadata

    Resolve Customer.email to an Intercom contact via POST /contacts/search. Read plan_tier from the subscription’s Price metadata (expand price if needed) — that stamp is the only tier source this workflow trusts.

  3. 03

    Score recent conversation volume and reopens

    POST /conversations/search over the rolling window for that contact, then GET each conversation for statistics.count_reopens. Sum into a frequency risk score; CSAT rating and agent priority tags are ignored.

  4. 04

    Three-source gate — escalate only when all clear

    Require (1) a qualifying Stripe cancel/downgrade signal, (2) frequency score above your calibrated threshold, and (3) plan_tier at or above the escalate floor (for example pro/enterprise — starter can log silently). Fail any leg → no Slack, no Linear.

  5. 05

    Tiered Slack alert with 10-minute customer dedup

    If the gate passes and this Stripe customer id has not alerted in the last 10 minutes, chat.postMessage to the mapped channel (and optional owner DM) with Stripe + Intercom evidence and Dashboard links. Repeat hits inside the window merge or skip.

  6. 06

    Linear retention issue for the mapped owner

    issueCreate with title, three-source description, retention label, assigneeId and priority from the same tier map. CS confirms before any customer-facing outreach — the issue is the work queue, not an auto-send.

  7. 07

    Slack message edited with the Linear issue URL

    chat.update fills the alert with the new issue link so one message holds the full context and a clickable task.

Set up each app

Work through these in order — later apps usually need a token or an id from an earlier one.

Stripe

Subscription cancel / downgrade signal — read-only

  1. 01

    Create a webhook destination for the two subscription events

    Dashboard → Workbench → Webhooks (`dashboard.stripe.com/webhooks`) → Create an event destination. Scope: Your account (not Connected accounts). Destination type: Webhook endpoint, HTTPS URL that is publicly reachable. Subscribe to exactly two event types: customer.subscription.updated (primary — cancel_at_period_end flips, or items downgrade) and customer.subscription.deleted (late fallback when the subscription actually ends). Do not subscribe to invoice.payment_failed here — failed charges belong to Stripe Smart Retries and automatic collection, not this retention path.

    Event types to subscribe (official cancel table)
    customer.subscription.updated
      — any subscription update, including cancel_at_period_end set to true
    customer.subscription.deleted
      — subscription canceled (direct delete, or cancel_at_period_end reaching period end)
    
    # Filter on .updated (not "every update"):
    #   scheduled cancel = object.cancel_at_period_end === true
    #                    && previous_attributes.cancel_at_period_end !== true
    #   downgrade        = previous_attributes.items present
    #                    && new price.unit_amount < old price.unit_amount
    # Source: docs.stripe.com/billing/subscriptions/cancel — Identify cancellation events
  2. 02

    Reveal the signing secret and verify Stripe-Signature

    Open the new endpoint → Click to reveal → copy the whsec_… signing secret into your secrets store. Every inbound POST must verify the Stripe-Signature header (HMAC-SHA256 over timestamp + body; default 5-minute skew) before you trust data.object or previous_attributes. The webhook itself carries no Bearer token — signature verification is the auth.

  3. 03

    Mint a read-only restricted key for lookups

    Dashboard → Developers → API keys → Create restricted key. Grant Read on Subscriptions, Customers, and Prices only — no Write on any resource. Use it when you need to expand price/product or fetch Customer.email after the webhook; the event payload already includes the subscription object, so most runs never need a second call. This workflow never refunds, cancels, or updates Stripe objects.

  4. 04

    Stamp plan_tier on every Price once

    Account tier for routing comes from Stripe Price metadata — one plan-level config your finance/product team owns, not from a third-party sync. Open each live Price → Metadata → set plan_tier to starter, pro, or enterprise (match the keys your Slack/Linear map will use). Check existing metadata keys first so you do not collide with an internal SKU field already in use. At runtime, read items[].price.metadata.plan_tier off the subscription (expand price if the snapshot omits metadata).

    Price metadata key
    Price.metadata.plan_tier = starter | pro | enterprise
    
    # Example: Pro monthly price
    # metadata[plan_tier]=pro

Intercom

Conversation frequency / reopen evidence + contact match

  1. 01

    Create a private app and Access Token

    developers.intercom.com → sign in → Your Apps → New app, pick the workspace. App detail → Authentication → generate the Access Token (Bearer). Private apps issue one token for the whole app; scoping is the next step on Permissions, not per-token.

  2. 02

    Scope Permissions to read-only conversation and contact data

    Permissions → enable Conversations (Read) and Contacts (Read) only. Leave Companies and all Write scopes off — plan_tier lives on Stripe Price metadata, so this workflow never reads Intercom company.plan and never writes custom_attributes back. Skip Tags / Articles; they are unused here.

  3. 03

    Confirm Stripe customer email matches Intercom contacts

    Identity bridge is email: Stripe Customer.email (GET /v1/customers/{id} when the webhook did not include it) → POST /contacts/search with an exact email match. Confirm billing and support share the same address set before go-live — a finance-only billing mailbox will silently miss the Intercom contact. Conversation.rating / sentiment are not used: rating only appears when a customer submits CSAT, and there is no native sentiment field; frequency and statistics.count_reopens are the systematic signals.

  4. 04

    Search recent conversations for that contact

    After you have the Intercom contact id, POST /conversations/search with contact_ids IN that id and created_at greater than now minus your window (7–14 days is a starting point — calibrate to your ticket volume; there is no official risk threshold). For each hit, GET /conversations/{id} and sum statistics.count_reopens. Priority and tag_ids are agent-applied labels, not automatic mood detection — do not treat them as evidence.

    POST https://api.intercom.io/conversations/search
    {
      "query": {
        "operator": "AND",
        "value": [
          { "field": "contact_ids", "operator": "IN", "value": ["<intercom_contact_id>"] },
          { "field": "created_at", "operator": ">", "value": 1754697600 }
        ]
      }
    }
    # created_at = Unix seconds for (now − N days).
    # Then GET /conversations/{id} → statistics.count_reopens per hit.

Slack

Tiered CS alert — only after the three-source gate passes

  1. 01

    Create the app and Bot Token Scopes

    api.slack.com/apps → Create New App → From scratch → OAuth & Permissions → Bot Token Scopes: chat:write (post and later edit the alert), users:read.email (resolve a CS owner email to a Slack user id for DMs). Install to Workspace and copy the Bot User OAuth Token into your secrets store. Invite the bot into every channel named in the tier map below.

  2. 02

    Maintain the plan_tier → channel / owner map

    Keep a config object — not a Slack UI setting — that maps plan_tier to a channel id and optional CS owner email (looked up with users.lookupByEmail). Example: enterprise → #cs-enterprise + DM the enterprise CSM; pro → #cs-general; starter → log only, no Slack ping. Use the same map for Linear assigneeId later so Slack and Linear never disagree on who owns the account.

  3. 03

    Dedup by Stripe customer id inside a 10-minute window

    chat.postMessage is Special Tier (about one message per second per channel, plus a workspace-wide cap). A customer who downgrades and then cancels in the same session would otherwise spam the channel and spawn duplicate Linear issues. Before posting, check a short-lived store keyed by Stripe customer id; if the same account already alerted within 10 minutes, merge into the existing thread (or skip) instead of posting again. This throttle is yours to implement — Slack has no native per-customer merge for bot messages.

    chat.postMessage — three-source Block Kit alert
    {
      "channel": "C0CSENTERPRISE",
      "text": "Churn risk — Acme Inc (enterprise)",
      "blocks": [
        { "type": "section",
          "text": { "type": "mrkdwn",
            "text": "*Acme Inc* · plan_tier `enterprise`\nStripe: `customer.subscription.updated` — cancel_at_period_end flipped true\nIntercom: 3 conversations / 7d, 1 reopen\n<https://dashboard.stripe.com/customers/cus_…|Stripe customer> · conversation ids 48213, 48240" } },
        { "type": "context",
          "elements": [{ "type": "mrkdwn",
            "text": "Linear issue link fills in after issueCreate. Dedup key: cus_… / 10m." }] }
      ]
    }

Linear

Retention work item for the mapped CS owner

  1. 01

    Create a personal API key

    Linear → Settings → Security & access → Personal API keys → New API key. Send it as the raw Authorization header value with no "Bearer " prefix — adding Bearer is the usual 400. Store the key in your secrets vault; this workflow only creates issues, never deletes them.

  2. 02

    Bootstrap teamId, states, and a retention label

    Run a one-time GraphQL query for teams (and their workflow states) and issueLabels. Create a label named retention (or churn-risk) in the Linear UI if it does not exist, then record its id. Map plan_tier → priority (enterprise → 1 Urgent, pro → 2 High, starter → 3 Normal) and plan_tier → assigneeId using the same owner table as Slack.

    Bootstrap teams + labels
    curl -s -X POST https://api.linear.app/graphql \
      -H "Authorization: $LINEAR_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{"query":"{ teams { nodes { id key name } } issueLabels { nodes { id name } } }"}'
  3. 03

    File the retention issue with issueCreate

    Only after the three-source gate passes and Slack dedup allows a new alert. Title = account name + signal summary; description concatenates the Stripe event fields, Intercom conversation ids (Intercom publishes no stable public conversation URL format — the id is the durable handle), and plan_tier. assigneeId and priority come from the tier map. A CS owner then confirms outreach before any customer-facing message — this step only opens the internal task.

    issueCreate — retention task
    # Single quotes are load-bearing: $input is a GraphQL variable, not a shell env.
    curl -s -X POST https://api.linear.app/graphql \
      -H "Authorization: $LINEAR_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "query": "mutation CreateIssue($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { id identifier url } } }",
        "variables": {
          "input": {
            "teamId": "3f0c9a51-7d2e-4b86-9a04-c1e5b83d7f62",
            "title": "Acme Inc — scheduled cancellation, 3 conversations in 7d",
            "description": "Stripe: customer.subscription.updated, cancel_at_period_end=true (was false)\nIntercom: 3 conversations in last 7 days, 1 reopen — ids 48213, 48240\nAccount tier: enterprise (Price.metadata.plan_tier)",
            "assigneeId": "<cs-owner-id-from-tier-map>",
            "labelIds": ["<retention-label-id>"],
            "priority": 1
          }
        }
      }'
    
    # => {"data":{"issueCreate":{"success":true,"issue":{
    #      "id":"c9f1...","identifier":"CS-218","url":"https://linear.app/acme/issue/CS-218"}}}}
    # Edit the Slack message with issue.url so one alert carries the task link.