Skip to main content

Setting up the Typeform integration

How to get started with the integration with Typeform

J
Written by Josefine Thoren
Updated over a week ago

Summary

  • Data you can sync

    • Typeform responses (answers, email, form title)

  • Sync direction

    • Typeform → Planhat (one-way)
      All submissions flow from Typeform into Planhat; no data is sent back to Typeform.

  • Sync frequency

    • Real-time (instant)
      Triggered automatically by Typeform’s webhook when a new submission is received.

Who's this article for?

  • All Planhat Admins

  • It's particularly relevant to those setting up the Typeform integration

Introduction

This automation captures Typeform submissions and automatically creates Conversations in Planhat while also:

  • Matching company by email domain

  • Handling subdomains like mail.fsu.edu → fsu.edu

  • Creating End Users if they don’t exist

  • Routing free email domains (Gmail, Outlook, Yahoo) to a “Personal Emails” company

  • Adding Q&A submission details into the conversation body

  • Letting you set custom fields like "Lead Source": "Demo Request"


Workflow summary

  1. Typeform sends webhook to Planhat

  2. Workflow Trigger = Webhook

  3. Function step runs SDK code

  4. Company resolved or created

  5. End user created/linked

  6. Conversation created in Planhat


Setting up in Planhat

  1. Go to Workflows → New Workflow

  2. Trigger Type = Webhook

  3. Click Copy URL

  4. In Typeform → Connect → Webhooks → Add webhook

  5. Paste the webhook URL

  6. Add a "Get" step on Company Object, configure it as below


  7. Add a Function step

  8. Paste code below

  9. Save & Enable

Function code

Paste this in Planhat Workflow Function:

// Step 2 — Typeform → Planhat (email-required, no Company in description)

const data = <<update>>;

const companies = <<Get Companies>> || [];

// ---- Typeform helpers ----

const fields = data.form_response?.definition?.fields || [];

const answers = data.form_response?.answers || [];

const byFieldId = (id) => answers.find(a => a.field?.id === id) || null;

const getAnswerLabel = (ans) => {

if (!ans) return "";

if (ans.type === "email") return ans.email || "";

if (ans.type === "text" || ans.type === "long_text") return (ans.text || "").trim();

if (ans.type === "choice") return ans.choice?.label || "";

if (ans.type === "choices") return (ans.choices?.labels || []).join(", ");

if (ans.type === "boolean") return ans.boolean ? "Yes" : "No";

if (ans.type === "url") return ans.url || "";

if (ans.type === "number") return String(ans.number ?? "");

return "";

};

const findByTitle = (re) => {

const f = fields.find(x => re.test(x.title || ""));

return getAnswerLabel(f && byFieldId(f.id)) || "";

};

const FREE_BASE_DOMAINS = new Set([

'gmail',

'googlemail',

'yahoo',

'ymail',

'outlook',

'hotmail',

'live',

'msn',

'icloud',

'me',

'mac',

'aol',

'protonmail',

'proton',

'pm',

'tutanota',

'tuta',

'yandex',

'mail',

'bk',

'list',

'inbox',

'gmx',

'zoho',

'seznam',

'wp',

'o2'

]);

function isFreeEmail(email) {

if (!email || !email.includes('@')) {

return false;

}

const domain = email.split('@')[1];

const baseDomain = domain.split('.')[0].toLowerCase();

return FREE_BASE_DOMAINS.has(baseDomain);

}

// ---- Robust email extraction ----

const emailRegex = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i;

let email = (answers.find(a => a.type === "email")?.email || "").trim();

if (!email) {

const emailishField = fields.find(f => (f.title || "").toLowerCase().includes("email"));

if (emailishField) {

const a = byFieldId(emailishField.id);

const raw = getAnswerLabel(a);

const m = raw.match(emailRegex);

if (m) email = m[0].trim();

}

}

if (!email) {

for (const a of answers) {

const raw = getAnswerLabel(a);

const m = raw.match(emailRegex);

if (m) { email = m[0].trim(); break; }

}

}

if (!email) throw new Error("Email is required to match/create contact.");

// ---- Optional fields (best-effort)

const firstName = findByTitle(/^first\s*name$/i);

const lastName = findByTitle(/^last\s*name$/i);

const companyName = findByTitle(/^(company\s*name|company)$/i);

const rawWebsite = findByTitle(/(website|url)/i);

// Prefer a “message” style field; else first long_text answer

let message = "";

{

const msgField = fields.find(f =>

/(message|notes|next\s*steps|feedback|bugs?)/i.test(f.title || "")

);

if (msgField) message = getAnswerLabel(byFieldId(msgField.id)) || "";

if (!message) {

const longText = answers.find(a => a.type === "long_text");

if (longText) message = getAnswerLabel(longText);

}

}

// ---- Resolve / create company (no domain required; use if present)

const cleanWebsite = (rawWebsite || "").replace(/^https?:\/\//, "").replace(/\/$/, "").toLowerCase();

const emailDomain = email.includes("@") ? email.split("@")[1].toLowerCase() : "";

let user = (await ph.models.endUsers.getAll({ email }))[0];

let resolvedCompanyId = user?.companyId || null;

// Try to find a company if user isn't already linked

if (!resolvedCompanyId) {

// 1) by exact company name (case-insensitive)

let company = null;

if (companyName) {

const norm = companyName.trim().toLowerCase();

company = companies.find(c => (c.name || "").trim().toLowerCase() === norm) || null;

}

// 2) by website (match website or domains)

if (!company && cleanWebsite) {

company = companies.find(c => {

const w = (c.website || "").replace(/^https?:\/\//, "").replace(/\/$/, "").toLowerCase();

const doms = (c.domains || []).map(d => (d || "").toLowerCase());

return w === cleanWebsite || doms.includes(cleanWebsite);

}) || null;

}

// 3) by email domain

if (!company && emailDomain) {

company = companies.find(c => (c.domains || []).some(d => (d || "").toLowerCase() === emailDomain)) || null;

}

// 4) create if still none

if (!company) {

const inferredName = companyName || (emailDomain && !isFreeEmail(email) ? emailDomain.split(".")[0] : "Unknown Company");

const body = { name: inferredName };

if (cleanWebsite) body.website = `https://${cleanWebsite}`;

if (emailDomain && !isFreeEmail(email)) body.domains = [emailDomain];

const created = await ph.models.companies.create(body);

resolvedCompanyId = created._id;

} else {

resolvedCompanyId = company._id;

}

}

// ---- Create / link end user

if (!user) {

user = await ph.models.endUsers.create({

firstName,

lastName,

email,

companyId: resolvedCompanyId,

custom: { "Lead Stage": "1 - New" }

});

} else if (!user.companyId && resolvedCompanyId) {

await ph.models.endUsers.update(user._id, { companyId: resolvedCompanyId });

}

// ---- Simple HTML summary (Company removed)

const htmlSummary = [

["First Name", firstName],

["Last Name", lastName],

["Email", email],

["Website", rawWebsite || ""],

["Message", message]

].filter(([_, v]) => v && String(v).trim())

.map(([k, v]) => `<strong>${k}:</strong> ${v}`)

.join("<br>\n");

// ---- Return (companyId required for Form Submission)

if (!resolvedCompanyId) return "No companyId resolved — Form Submission requires a company.";

const conversationPayload = {

subject: `Inbound from ${firstName || lastName ? `${firstName} ${lastName}`.trim() : email}`,

type: "Form Submission",

companyId: resolvedCompanyId,

endusers: [user],

description: htmlSummary,

custom: {

"Lead Stage": "1 - New",

"Form Submission": htmlSummary

}

};

return await ph.models.conversations.create(conversationPayload);


Customize the integration

Goal

Edit

Change conversation type

Change "type": "Form Submission"

Add more fields

Inside "custom": {}

Assign internal rep

Add users: [{ email: "rep@yourcompany.com" }]

Tag form by source

"Form Name": formTitle


Testing

  • Submit Typeform with business email → links company

  • mail.fsu.edu style → resolves correctly

  • Gmail/outlook email → goes to Personal Emails bucket

Did this answer your question?