Customer testimonial pipeline: Intercom → Notion → Slack → Gmail
A praise tag in Intercom becomes a Notion quote, a Slack ping, and a Gmail draft you send.
Praise in support threads usually dies there. A team-defined Intercom tag pulls the thread and has AI lift out the actual quote. The AI line is a candidate, not a cleared quote — marketing still reads it in Notion and in the Gmail draft before anything reaches the customer; a misquote on a public page is worse than praise left buried. It lands in Notion, pings Slack, and opens a Gmail permission draft — nothing sends until a person reads it. Intercom's Notion connection only pulls docs into Fin AI, the other direction, so it doesn't compete.
How it flows
- 01
Agent tags the conversation
A support agent applies the team's own praise tag (whatever name they created in setup — not an Intercom default) to an Intercom conversation in the Inbox — that click is the only human action that starts this flow.
- 02
Webhook fires
conversation_part.tag.created delivers the conversation id and the tag. Contact-level tag topics fire on a profile instead of this conversation, so they are not the trigger here.
- 03
Full conversation pulled and cleaned
GET /conversations/{id} returns source.body plus every conversation_parts[].body as HTML, and source.author.{name,email} for the customer. HTML tags are stripped before anything moves to the next step.
- 04
AI extracts the actual quote
Reads the cleaned thread and pulls out the specific line worth quoting — praise, an NPS recommendation, a concrete before/after — instead of filing the whole thread as the testimonial. This step nominates a line; it does not clear it for public use.
- 05
Duplicate check before writing
Query the Notion testimonials data source for an existing row whose Source conversation URL matches this conversation's deep link. If one exists, skip create — retagging the same conversation re-fires conversation_part.tag.created, and this check is what stops filing the same quote twice.
- 06
New row lands in Notion
POST /v1/pages, parented on the data source id from setup, not database_id. Permission status starts at Not requested — nothing about this row implies the customer has agreed to anything yet.
- 07
Slack ping to marketing
chat.postMessage to the marketing channel with the quote excerpt, the customer's name and company, the new Notion page link, and the source conversation link — the cross-app aggregation Intercom's own Slack notification can't produce.
- 08
Permission email drafted — human sends
AI drafts a short note asking the customer whether the quote can be used publicly, addressed to source.author.email, and creates it with drafts.create — the send endpoint is never called. Marketing reviews the draft in Gmail, edits if needed, and presses send themselves; if the author's email was missing, this step is skipped and the Notion row is flagged for a human follow-up.
Set up each app
Work through these in order — later apps usually need a token or an id from an earlier one.
Intercom
Trigger and only data source — never written back to
- 01
Create the app and generate an Access Token
developers.intercom.com → sign in → Developer Hub → Your Apps → New app, pick the workspace this reads from. A private app that only reads its own paid workspace's data skips Intercom's review process. Open the app → Authentication tab → generate the Access Token (Bearer).
- 02
Scope Permissions to Conversations: Read — nothing else
Permissions tab → turn on Conversations: Read and leave every other resource off: no Contacts, no Tags, no Write of any kind. GET /conversations/{id} already returns source.author.{id,type,name,email} inline, so the customer's email never needs a separate /contacts call, and this flow only ever writes to Notion — it never tags, notes, or replies inside Intercom itself.
- 03
Create the trigger tag — a name your team invents
Settings → Tags → New tag. Pick any name your team already uses for praise or an NPS recommendation — customer-love below is an example only, not an Intercom-built-in value. A support agent applying that tag to a conversation in the Inbox is the one human action that starts this whole flow.
- 04
Subscribe the webhook to the tag-applied topic
Webhooks tab → New webhook → set the Endpoint URL → subscribe to conversation_part.tag.created — Intercom's only topic for "a tag landed on this conversation." contact.user.tag.created and contact.lead.tag.created fire on a contact profile instead, so they don't apply here; there is no conversation.tag.created at all, despite how natural that name sounds. Tag a test conversation once before going live and check whether the payload gives you the tag name inline or only a tag id you'd have to resolve separately. If the payload only carries a tag id, copy that id into the job's allow-list once during setup and filter on it — do not turn on Tags: Read just to resolve the name on every event. Conversations: Read stays the only Intercom permission this flow needs.
Webhook topic to subscribeconversation_part.tag.created - 05
Know the response shape before you build the extraction step
GET /conversations/{id} returns source.body as the opening message and conversation_parts.conversation_parts[].body for every reply after it — both HTML, not plain text, capped at the most recent 500 parts. Strip the tags before handing any of this to an extraction model, or the quote you save will have markup baked into it. Take the customer email from source.author.email on this same response.
GET /conversations/{id} — fields this flow readssource.body # opening message, HTML source.author.{id,type,name,email} # customer identity — email included, no /contacts call needed conversation_parts.conversation_parts[].body # replies, HTML, most recent 500
Notion
The testimonials database — the one durable record
- 01
Create an internal integration
notion.so/my-integrations → New integration → pick the workspace → copy the Internal Integration Secret.
- 02
Create the "Customer Testimonials" database
New page → Table (or type /database inside an existing page). Add exactly these properties — names matter, the API writes to them by name: Name (Title), Company (Text — optional; leave empty if you cannot infer it from the thread, and do not block the write), Quote (Text), Source conversation (URL), Tag type (Select: Praise / NPS promoter / Case study candidate), Permission status (Select: Not requested / Requested / Granted / Declined), Captured at (Date).
- 03
Add the integration to the database
Open the database → ••• (top right) → Connections → Add connection → pick the integration from step 1 → confirm access. Skip this and every write 404s on a database you can plainly see in the UI.
- 04
Retrieve the data source id once — required, not optional
GET /v1/databases/{database_id} once and keep its data_source_id; parent every page this flow creates on that id, not database_id. Notion's 2025-09-03 model split database from data source, and its own migration guide names "create a page with the database as parent" as one of the calls that breaks first — not immediately, but the day this database gains a second data source. This is a new database with no legacy callers, so start on data_source_id. Before every create, also query this same data source for an existing row whose Source conversation URL already matches the Intercom deep link `app.intercom.com/a/inbox/{workspace}/conversation/{id}` (workspace segment per your team; full example in the create-page snippet) — retagging the same conversation re-fires the webhook, and that check is what stops a duplicate row.
POST https://api.notion.com/v1/pages · Notion-Version: 2026-03-11{ "parent": { "type": "data_source_id", "data_source_id": "8f2c1d0e4b7a4c9f8e1d2c3b4a5f6e7d" }, "properties": { "Name": { "title": [{ "text": { "content": "Jamie Chen" } }] }, "Company": { "rich_text": [{ "text": { "content": "Northwind Co." } }] }, "Quote": { "rich_text": [{ "text": { "content": "This is the first tool that actually saved my team a full day every week." } }] }, "Source conversation": { "url": "https://app.intercom.com/a/inbox/xyz/conversation/48213" }, "Tag type": { "select": { "name": "Praise" } }, "Permission status": { "select": { "name": "Not requested" } }, "Captured at": { "date": { "start": "2026-08-09" } } } } - 05
Dedup filter shape for Source conversation
Query the testimonials data source with a URL equals filter on Source conversation before every create. Endpoint is the data source query; Notion-Version matches the create-page step.
data source query — Source conversation URL equals{ "filter": { "property": "Source conversation", "url": { "equals": "https://app.intercom.com/a/inbox/xyz/conversation/48213" } } }
Slack
Tells marketing a testimonial candidate exists
- 01
Create the app and add the one scope it needs
api.slack.com/apps → Create New App → From scratch → OAuth & Permissions → Bot Token Scopes → add chat:write. This step is a one-way broadcast into a fixed channel — no approval buttons, so no users:read.email or other identity-lookup scopes.
- 02
Install to Workspace and invite the bot
Install to Workspace → copy the Bot User OAuth Token into the execution environment's secrets store → /invite @your-app-name into #marketing-testimonials (or whatever channel your team wants these landing in).
- 03
Post the aggregated notification, not a plain mention
Intercom's own Workflows already have a "Notify Slack channel" action you could point at this same trigger tag — but it can only post fields Intercom itself has, like the conversation link and the customer's name. It cannot include the Notion page this flow just created or the cleaned quote AI pulled out of the thread, because neither exists yet at the moment Intercom would fire that native action. This step is the one that actually aggregates across apps.
POST https://slack.com/api/chat.postMessage{ "channel": "#marketing-testimonials", "text": "New testimonial candidate from Jamie Chen (Northwind Co.) — <https://notion.so/xyz|view in Notion> · <https://app.intercom.com/a/inbox/xyz/conversation/48213|source conversation>" }
Gmail
Holds the permission ask — draft only, human sends
- 01
Enable the Gmail API and create an OAuth client
Google Cloud Console → new or existing project → enable the Gmail API → create an OAuth client.
- 02
Set the consent screen to Testing, and use the compose scope
OAuth consent screen → Internal (Workspace) or External with Testing status, using your own address as the test user — that keeps this out of Google's verification queue and the annual CASA security assessment that publishing to real external users triggers. Use the gmail.compose scope below. Google documents it as "Manage drafts and send emails," not "manage drafts only" — the scope itself can send. Nothing about it stops that; the next step does, by construction.
The scope valuehttps://www.googleapis.com/auth/gmail.compose - 03
Authorize once to mint a refresh token
Walk through the OAuth authorization flow one time to get a refresh token, then store it in the execution environment's secrets store alongside the client id and secret. This click is the one step in Gmail setup that has to happen in a browser.
- 04
Create the draft — marketing sends it by hand
POST /gmail/v1/users/me/drafts (full URL in the snippet) with the customer's email from Intercom's source.author.email as the recipient, and AI-drafted body text that quotes the extracted line and asks whether it can be used as a case study or testimonial. This job never calls users.messages.send; that is a property of the code, not of the scope, since drafting and sending share the same permission. Marketing opens the draft in Gmail, edits it if they want, and clicks send themselves — the human gate on every outbound permission ask. If source.author.email came back empty — a channel Intercom didn't attach an address to — skip this step and flag the Notion row for a human to follow up on directly.
POST /gmail/v1/users/me/drafts{ "message": { "raw": "<base64url(RFC2822 message: To: jamie@northwind.co\r\nSubject: Quick permission check on your kind words\r\n\r\nHi Jamie, ...)>" } }