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
Typeform sends webhook to Planhat
Workflow Trigger = Webhook
Function step runs SDK code
Company resolved or created
End user created/linked
Conversation created in Planhat
Setting up in Planhat
Go to Workflows → New Workflow
Trigger Type = Webhook
Click Copy URL
In Typeform → Connect → Webhooks → Add webhook
Paste the webhook URL
Add a "Get" step on Company Object, configure it as below
Add a Function step
Paste code below
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


