Inbound lead flow: Typeform → HubSpot → Slack
Form submitted, contact upserted in HubSpot, owner assigned and pinged in Slack.
The gap between a demo request and the first human reply is the whole conversion story. This workflow takes a Typeform submission, upserts a HubSpot contact by email so repeat submitters do not fork into duplicates, scores the lead from company size and intent, assigns an owner round-robin, and pings that person in Slack with everything they need to reply in one minute.
How it flows
- 01
Demo form submitted
Typeform posts form_response with hidden UTM fields attached.
- 02
Signature verified, answers read by ref
Stable refs mean a reordered form does not silently scramble the data.
- 03
Contact upserted in HubSpot
batch/upsert keyed on email — creates the first-time lead, updates the repeat submitter, forks neither.
- 04
Lead scored and owner assigned
Score from size, timeline and domain; owner picked round-robin among available reps.
- 05
Rep pinged in Slack
Full context plus a deep link to the HubSpot contact.
- 06
Low scores skip the ping
Under 40 the contact is still created, but it goes to nurture instead of interrupting a human.
Set up each app
Work through these in order — later apps usually need a token or an id from an earlier one.
Typeform
Capture, including the attribution fields
- 01
Add URL parameters for attribution
Form editor → Settings → URL parameters (the feature Typeform used to call Hidden fields) → add utm_source, utm_campaign, referrer. Append them to the form URL in your ads and they ride along on the submission — retro-fitting attribution later is impossible. The payload key is still "hidden", so nothing in your code changes.
- 02
Register the webhook
Form → Connect → Webhooks → Add a webhook → your endpoint → Save, then toggle it on (it defaults to off). Set a secret and verify the Typeform-Signature header; the endpoint is public and will get probed. Worth saying out loud: Typeform ships a native HubSpot connector that creates and updates contacts with a question-to-property mapping, and if that is all you need you should use it. This workflow goes custom because the connector cannot score the lead or round-robin an owner.
- 03
Read answers by field ref, not position
Give every question a stable ref in the editor (Question → Settings menu → Block references). Positional indexing breaks the first time someone reorders the form.
form_response payload (trimmed){ "event_id": "01JQ8W2M4K", "event_type": "form_response", "form_response": { "form_id": "kX7dQp2N", "submitted_at": "2026-01-15T10:24:11Z", "hidden": { "utm_source": "linkedin", "utm_campaign": "q1-demo", "referrer": "acme.com/pricing" }, "answers": [ { "field": { "ref": "work_email" }, "type": "email", "email": "lee@northwind.co" }, { "field": { "ref": "company_size" }, "type": "choice", "choice": { "label": "51-200" } }, { "field": { "ref": "timeline" }, "type": "choice", "choice": { "label": "This quarter" } }, { "field": { "ref": "notes" }, "type": "text", "text": "Replacing a homegrown tool, 40 seats." } ] } }
HubSpot
System of record for the contact
- 01
Create a private app token
HubSpot → Development → Legacy apps → Create legacy app → Private (private apps were renamed legacy apps). Scopes: crm.objects.contacts.read, crm.objects.contacts.write, crm.objects.owners.read. The token is on the Auth tab behind "Show token" and stays retrievable, so a lost token is an inconvenience rather than a rebuild — treat it as a secret anyway.
- 02
Create the custom properties first
Settings → Properties → Create property for lead_score (number), inbound_timeline (dropdown), utm_source (single-line text). Writing to a property that does not exist fails the whole request, not just that field.
- 03
Upsert by email instead of creating
Use the batch upsert endpoint: it updates a match on email and creates the contact when there is none, which is the common case on an inbound form. PATCH /contacts/{id} only ever updates, so it 404s on every first-time lead — keep it for contacts you already know exist. One documented catch: partial upserts are not supported when email is the idProperty, so send the full property set every time, or key on a custom unique property instead. (URLs here use /crm/v3/; HubSpot has moved to date-based versions like /crm/objects/2026-03/contacts, and v3 keeps working with no announced end of life.) lifecyclestage is forward-only through the API: a repeat submitter already at Sales Qualified Lead or Customer will silently ignore the marketingqualifiedlead write, which matters here because repeat submitters are the whole premise.
POST contacts/batch/upsertcurl -X POST "https://api.hubapi.com/crm/v3/objects/contacts/batch/upsert" \ -H "Authorization: Bearer $HUBSPOT_PRIVATE_APP_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "inputs": [{ "id": "lee@northwind.co", "idProperty": "email", "properties": { "email": "lee@northwind.co", "company": "Northwind", "lead_score": "78", "inbound_timeline": "this_quarter", "utm_source": "linkedin", "hubspot_owner_id": "51409223", "lifecyclestage": "marketingqualifiedlead" } }] }'
Slack
Gets the assigned rep replying fast
- 01
Score before you route
Keep the scoring table in one place and boring enough that sales can argue with it. Anything above 70 goes to a human immediately; below 40 goes to the nurture sequence and never touches Slack.
lead scorecompany_size 201+ +40 51-200 +30 11-50 +15 1-10 +5 timeline this quarter +30 next quarter +15 exploring +0 email domain business +10 free provider -20 utm_source linkedin/referral +8 - 02
Round-robin the owner
Persist the last-assigned index so restarts do not always hand the next lead to the same person. Skip reps whose Slack presence is away (users.getPresence returns "active" or "away") or who are on the OOO list.
- 03
Give the app a token that can post
api.slack.com/apps → your app → OAuth & Permissions → Bot Token Scopes → chat:write to post, users:read for the presence check above. Install to the workspace, invite the bot to the channel, and send the ping with chat.postMessage using the bot token.
chat.postMessagecurl -X POST https://slack.com/api/chat.postMessage \ -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ -H "Content-Type: application/json; charset=utf-8" \ -d '{"channel":"C06INBOUND","text":"Northwind · 51-200 · this quarter · score 78","blocks":[]}' - 04
Ping with everything needed to reply
Company, size, timeline, their own words, and a direct link to the HubSpot record. If the rep has to go looking for context, the ping has failed.