WorkflowsMCP

Relationship health survey: Typeform → HubSpot → Slack

Score quarterly NPS, flag detractors, and route a HubSpot task plus Slack alert to the owner.

@workflowsmcpVerified customer-feedbacksupportnpscustomer-successaccount-management

A quiet account is not the same as a happy one — the ones who never file a support ticket can be just as close to leaving. This workflow sends a quarterly NPS-style survey through Typeform; any score of 0-6 opens a HubSpot task routed by the account's tier and posts a Slack alert with the verbatim comment and a link to the task, so the owner acts within the day instead of finding out at renewal. Promoters and passives just update the score on the contact — no task, no noise.

How it flows

  1. 01

    Quarterly send goes out

    A person reviews the merged recipient list and clicks Send in HubSpot; the email carries each contact's personalized Typeform link with customer_id and tier attached.

  2. 02

    Customer submits the survey

    Typeform fires form_response with the NPS answer, the verbatim comment, and the hidden customer_id/tier values.

  3. 03

    Signature verified, answers read by ref

    Stable refs mean a reordered form does not silently scramble which answer is the score and which is the comment.

  4. 04

    Detractor threshold applied

    0-6 on a 0-10 scale is the standard NPS detractor line, not a Typeform setting — it has to be re-derived, not copy-pasted, the day the question moves to a different scale.

  5. 05

    Detractor: task created and routed by tier

    Tier is read off the contact's plan_tier property; the task is created and dropped into the VIP or standard queue with priority set accordingly.

  6. 06

    Slack alert posted to the account owner

    Score, the verbatim comment, and a deep link straight to the HubSpot task.

  7. 07

    Non-detractors skip the noise

    Passive and promoter responses only update health_survey_score and health_survey_segment on the contact — no task, no Slack ping.

Set up each app

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

Typeform

Asks the question and carries the account identity

  1. 01

    Build the survey

    Add an Opinion Scale question set to 11 steps (0-10) — that is Typeform's mechanism for a classic NPS-style question; the field defaults to 0-10 already, and 1-5 is the minimum it can be configured down to. Add one open-text follow-up, "What's the one thing we could do better?", for the verbatim comment that lands in the Slack alert. Give both questions a stable ref via the question's Settings menu → Block references — positional indexing breaks the first time someone reorders the form.

  2. 02

    Add URL parameters for the account identity

    Typeform does not send email itself, so a personalized send works by appending identity to the form URL and distributing that URL through a channel that already owns the relationship. Form editor → Settings → URL parameters (Typeform's current name for what it used to call Hidden Fields) → add customer_id and tier. Every submission then carries both values in the webhook payload's "hidden" object with no extra lookup.

  3. 03

    Register and sign the webhook

    Form → Connect → Webhooks → Add a webhook → your endpoint → Save, then toggle it on — it defaults to off. Set a signing secret and verify the Typeform-Signature header (format sha256=<base64 HMAC-SHA256 of the raw body>); the endpoint is public and will get probed. Worth saying out loud: Typeform's own HubSpot connector only maps form answers to CRM properties, and its Slack connector only pings on every completion — neither reads the answer value, so neither can single out a detractor or open a CRM task. Typeform also has its own NPS scoring built into the Opinion Scale block, but that number stays inside Typeform's dashboard and never reaches HubSpot or Slack. Intercom's own CSAT only fires when a support conversation closes, so it never reaches the accounts that are quiet precisely because they're either fine or already drifting — the population this workflow exists to reach. That gap is why this goes custom.

  4. 04

    Read the payload by field ref

    The webhook fires form_response with the two hidden values, the NPS answer, and the comment, each tagged by the ref set above rather than by position.

    form_response payload (trimmed)
    {
      "event_id": "01JXQHEALTH9",
      "event_type": "form_response",
      "form_response": {
        "form_id": "hLTh42Fp",
        "token": "a3a12ec67d",
        "submitted_at": "2026-08-09T14:02:11Z",
        "hidden": { "customer_id": "cust_88213", "tier": "enterprise" },
        "definition": {
          "fields": [{ "id": "nps01", "type": "opinion_scale", "ref": "relationship_score" }]
        },
        "answers": [
          { "type": "number", "number": 4, "field": { "ref": "relationship_score" } },
          { "type": "text", "text": "Support is great but pricing keeps going up.", "field": { "ref": "improve_text" } }
        ]
      }
    }

HubSpot

System of record for the score, and where the follow-up task lives

  1. 01

    Create a private app token

    HubSpot → Settings → Integrations → Private Apps → Create a private app (a different, already-shipped Typeform+HubSpot seed's setup calls this path "Development → Legacy apps" — HubSpot has used both labels for the same private-app feature; use whichever your account's left nav currently shows). Scopes: crm.objects.contacts.read and crm.objects.contacts.write — HubSpot's own Tasks API reference lists exactly these two as the full scope requirement, so no separate task-specific scope is needed.

  2. 02

    Create the custom properties first

    Settings → Properties → Create property, on the Contact object: health_survey_score (number), health_survey_segment (dropdown: detractor / passive / promoter), and plan_tier (dropdown, matching the values you send as the Typeform "tier" URL parameter — e.g. starter / growth / enterprise). Writing to a property that does not exist fails the whole request, not just that field. HubSpot has no single standard "customer value" property; plan_tier here is this workflow's own choice, not a HubSpot-defined field. No customer_id property is needed: when you build the quarterly send, populate the Typeform URL's customer_id parameter with the contact's own HubSpot record id via the {{ contact.hs_object_id }} personalization token, not an external identifier. That way the customer_id the webhook echoes back is already the HubSpot contact id — it is what CONTACT_ID means in the task snippet below, and it is the contact whose plan_tier you read for queue routing. No separate contact lookup or search call is needed.

  3. 03

    Set up Task Queues and wire the detractor task

    CRM → Tasks → Manage queues → Create task queue, twice: one for VIP escalations, one standard. Associate the created task to the contact so it shows up on their timeline. associationTypeId 204 is HubSpot's own default for this exact direction — Task associated to Contact — per HubSpot's Associations v4 default-types table and the Tasks API's own example request; 203 is the reverse direction (Contact to Task) and is not what this snippet needs.

    POST crm/v3/objects/tasks
    curl -X POST "https://api.hubapi.com/crm/v3/objects/tasks" \
      -H "Authorization: Bearer $HUBSPOT_PRIVATE_APP_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "properties": {
          "hs_task_subject": "Detractor follow-up: Northwind (score 4/10)",
          "hs_task_body": "Relationship health survey detractor. Comment: \"Support is great but pricing keeps going up.\"",
          "hs_task_status": "NOT_STARTED",
          "hs_task_priority": "HIGH",
          "hs_timestamp": "1754755200000",
          "hubspot_owner_id": "51409223"
        },
        "associations": [{
          "to": { "id": "CONTACT_ID" },
          "types": [{ "associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 204 }]
        }]
      }'
  4. 04

    Build the quarterly send

    There is no email app in this workflow, and Typeform cannot send its own — so the quarterly wave goes out as a HubSpot marketing email (or a HubSpot Workflow-driven email) to the active-customer list, personalizing each contact's link with the customer_id and tier URL parameters from the Typeform setup above as a merge token. That merge token should be the contact's {{ contact.hs_object_id }} value (see the custom-properties step above) — the id sent to Typeform is the same id you match on when the response comes back. A single token forming a whole link is a documented, working pattern — edit the email's source HTML and set the href to one token, e.g. {{ contact.hs_object_id }} as shown above. Concatenating two separate tokens into one href as two query parameters is not a documented pattern; if it does not render correctly in your account, fall back to precomputing the finished customer_id+tier URL into a single property (outside HubSpot, or via a HubSpot workflow action) and merge in that one value instead. Either way, a person reviews the merged recipient list and clicks Send in HubSpot for each quarterly wave — this workflow hands HubSpot a ready-to-send list and stops there, it does not auto-fire the customer-facing email itself.

Slack

Gets the account owner acting the same day

  1. 01

    Give the app a token that can post

    api.slack.com/apps → your app → OAuth & Permissions → Bot Token Scopes → chat:write (post the detractor alert), plus users:read if you want to look up the account owner's Slack user by email and DM them instead of posting to a channel. Install to the workspace and invite the bot wherever it needs to post.

  2. 02

    Wire the detractor-alert target

    No native score-conditional filtering exists in Slack — "only post for detractors, only make noise for high-value ones" is entirely this workflow's own branch, not a Slack feature. Post the account name, tier, score, the verbatim comment, and a deep link to the HubSpot task.

    chat.postMessage
    curl -X POST https://slack.com/api/chat.postMessage \
      -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
      -H "Content-Type: application/json; charset=utf-8" \
      -d '{"channel":"C0HEALTHVIP","text":"Northwind (Enterprise) scored 4/10 on the relationship survey — HubSpot task created and routed to the VIP queue.","blocks":[]}'