WorkflowsMCP

VIP refund approval: Intercom → Slack → Stripe

Slack approval gates every Stripe refund an Intercom tag requests — no click, no charge.

@workflowsmcpVerified supporttriagecustomer-feedbackbillingapproval

VIP refunds need a human in the loop before any charge moves — Intercom's own Stripe app never issues one, only shows billing data. Tagging a conversation vip-refund-request sends an Approve/Reject request to a whitelisted Slack approver; zero Stripe calls happen until that click is verified. Approve fires one POST to /v1/refunds on a refund-only restricted key, keyed for idempotency so retries can't double-refund. Reject or silence ends it with no charge touched; either outcome lands in Intercom as a note — its API has no draft reply type.

How it flows

  1. 01

    Agent tags the conversation

    A support agent applies vip-refund-request to an Intercom conversation — the only human action that starts this flow.

  2. 02

    Webhook fires, refund context assembled

    conversation_part.tag.created triggers the workflow. There is no official Intercom↔Stripe API that maps a conversation to a specific charge id — the agent applying the tag reads the charge id and amount off the Stripe payment details already visible in the Intercom Inbox (via the Stripe app) and carries them into the Slack approval step below.

  3. 03

    Approval request posted to Slack — zero Stripe calls before this

    chat.postMessage sends the amount, customer, conversation link and reason to the whitelisted approver with Approve and Reject buttons. Nothing has touched Stripe yet, and nothing will until one of those buttons is clicked by someone on the allowlist.

  4. 04

    Reject ends it — no refund, no ambiguity

    A Reject click closes the request with zero Stripe calls, and Intercom gets a note recording the rejection. An approver who never responds simply leaves the request pending — this workflow has no scheduled job to detect silence or auto-close/escalate it; add one separately if a hard timeout matters for your team.

  5. 05

    Approve triggers a single idempotent refund

    The click is verified against the approver allowlist first; only then does POST /v1/refunds run, on the refund-only restricted key, with an Idempotency-Key derived from the conversation and approval time — not a random value, so a retried callback cannot double-refund.

  6. 06

    Refund logged back into Intercom as a note

    POST /conversations/{id}/reply with message_type: note records the refund id, amount and time. There is no draft reply type in Intercom's API, so this is an internal note the agent reads and turns into their own customer-facing message.

  7. 07

    Slack message updated to show the outcome

    The original approval message is edited to show Executed or Rejected, so nobody re-clicks a stale button or wonders whether the refund actually went through.

Set up each app

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

Intercom

Where the request starts, and where the paper trail ends

  1. 01

    Create the app and generate an Access Token

    developers.intercom.com → sign in → Your Apps → New app, pick the workspace. Open the app → Authentication tab → generate the Access Token (Bearer). Intercom's private apps hand out one token for the whole app; the actual scoping happens next, on the Permissions tab, not per-token.

  2. 02

    Scope Permissions to the two resources this touches

    Permissions tab → turn on Conversations (Read + Write — write is for the note reply below) and Tags (Read, to know which tag fired). Leave Contacts off: this workflow never edits a customer record, only reads and annotates a conversation.

  3. 03

    Create the trigger tag

    Settings → Tags → New tag → vip-refund-request (name it whatever your team already calls this). A support agent applying this tag to a conversation is the one human action that starts the whole flow — nothing else in Intercom triggers it.

  4. 04

    Subscribe the webhook to the tag-applied topic

    Webhooks tab → New webhook → set the Endpoint URL → subscribe to conversation_part.tag.created. That is the only tag-related topic in Intercom's webhook catalog — there is no conversation.admin.tagged event, so do not build the subscription around that guess. Before trusting the payload in production, tag a test conversation once and check whether the tag name comes back inline or only a tag id you'd need to resolve separately.

    Webhook topic to subscribe
    conversation_part.tag.created
  5. 05

    Format of the note the flow writes back

    Every outcome — refunded or rejected — posts to POST /conversations/{conversation_id}/reply with message_type set to note, not comment: a note is internal-only, never sent to the customer, which is the closest official equivalent to a draft since Intercom's message_type enum has no draft value.

    POST /conversations/{id}/reply — internal note
    {
      "message_type": "note",
      "type": "admin",
      "admin_id": "<bot-admin-id>",
      "body": "Refund re_1P... for $128.00 processed via Stripe (approved by @finance in Slack). Draft a customer-facing reply confirming the refund before closing."
    }

Slack

The approval gate — nothing downstream moves without a click here

  1. 01

    Create the app and scopes

    api.slack.com/apps → Create New App → OAuth & Permissions → Bot Token Scopes: chat:write (post the approval request and the outcome), users:read.email (resolve the approver's email to a Slack id for the allowlist check below).

  2. 02

    Turn on Interactivity for the Approve/Reject buttons

    Interactivity & Shortcuts → toggle Interactivity on → set a Request URL. Slack POSTs the button click there as a form-encoded payload and expects an ack inside 3 seconds — ack first (e.g. replace the message text with "Processing…"), then call Stripe. No Stripe call happens before that click arrives and is verified; that ordering is the entire point of this gate, and it must not be short-circuited for convenience.

  3. 03

    Install to Workspace and copy the Bot Token

    OAuth & Permissions → Install to Workspace → copy the Bot User OAuth Token into the execution environment's secrets store.

  4. 04

    Maintain the refund-approver allowlist

    Keep a short list of Slack user ids for people actually authorized to approve refunds — finance or support leads, not the whole workspace. The Interactivity handler must check the clicking user's id against this list before doing anything with the click and silently ignore clicks from anyone else; there is no native Slack setting for "can approve refunds", so this list lives in your own config.

  5. 05

    Approval message format

    chat.postMessage with two buttons sharing the context needed downstream: action_id tells the handler which branch to take, value carries the conversation id, charge id and amount so the handler never has to re-fetch them before deciding. Charge id and amount come from the agent reading the Stripe payment details already visible in the Intercom Inbox — there is no official Intercom↔Stripe API that resolves a conversation straight to a charge id.

    Approval DM — Approve/Reject buttons
    {
      "channel": "<finance-approver-slack-id>",
      "text": "VIP refund approval needed",
      "blocks": [
        { "type": "section",
          "text": { "type": "mrkdwn",
            "text": "*Refund request* — $128.00 for cus_9fK2Lp (VIP)\nConversation: <https://app.intercom.com/a/inbox/xyz/conversation/48213|#48213>\nReason: duplicate charge" } },
        { "type": "actions",
          "elements": [
            { "type": "button", "style": "primary", "text": { "type": "plain_text", "text": "Approve refund" },
              "value": "refund:conv_48213:ch_3P2h9x2eZvKYlo2C1a2b3c4d:12800", "action_id": "vip_refund_approve" },
            { "type": "button", "style": "danger", "text": { "type": "plain_text", "text": "Reject" },
              "value": "refund:conv_48213:reject", "action_id": "vip_refund_reject" }
          ] }
      ]
    }

Stripe

Executes the refund, and only the refund, and only after Slack says so

  1. 01

    Create a restricted key scoped to refunds only

    dashboard.stripe.com/apikeys → Create restricted key. Set Refunds to Write, Charges to Read (needed to look up the charge before refunding it), and leave every other resource — Customers, Payouts, Subscriptions, everything else — at None. Write implies Read, so Refunds: Write alone covers creating a refund.

  2. 02

    (Optional) Lock the key to your execution server's IP

    Dashboard → Access policies → bind the restricted key to an IP allowlist covering only the server that calls /v1/refunds. A leaked key with no IP restriction still works from anywhere; this closes that gap for a small amount of extra setup.

  3. 03

    Store the key in a secrets vault, not in code

    Put the restricted key in the execution environment's secret store. It never belongs in a repo, a log line, or the Slack message itself — the approval message only needs the charge id and the amount, never the key.

  4. 04

    Call the refund — only after the approval click is verified

    Once the Approve click is confirmed against the Slack allowlist, POST to /v1/refunds with the refund-only restricted key. Idempotency-Key is not a random value: derive it from a stable id for this approval (the conversation id plus the approval timestamp, or a database row's primary key) so a retried callback or network hiccup replays the same result instead of issuing a second refund.

    POST https://api.stripe.com/v1/refunds
    curl https://api.stripe.com/v1/refunds \
      -u "rk_live_<refund-only-restricted-key>:" \
      -H "Idempotency-Key: vip-refund-approval-conv_48213-<approval-timestamp-or-record-id>" \
      -d charge=ch_3P2h9x2eZvKYlo2C1a2b3c4d \
      -d amount=12800 \
      -d reason=duplicate \
      -d metadata[intercom_conversation_id]=48213 \
      -d metadata[approved_by_slack_user]=U04FINANCE