Churn-risk account flag: Stripe → Intercom → Slack → Linear
Cancel or downgrade plus Intercom chatter → tiered Slack alert and a Linear retention task.
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
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
- 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 - 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.
- 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.
- 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 keyPrice.metadata.plan_tier = starter | pro | enterprise # Example: Pro monthly price # metadata[plan_tier]=pro
Intercom
Conversation frequency / reopen evidence + contact match
- 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.
- 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.
- 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.
- 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
- 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.
- 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.
- 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
- 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.
- 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 + labelscurl -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 } } }"}' - 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.