This workflow corresponds to n8n.io template #13555 — we link there as the canonical source.
This workflow follows the Form Trigger → Gmail recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"meta": {
"templateCredsSetupCompleted": true
},
"nodes": [
{
"id": "63171447-5c6f-4608-b65d-1a987271af89",
"name": "\ud83d\udce7 Send Competition Report",
"type": "n8n-nodes-base.gmail",
"position": [
2416,
1296
],
"parameters": {
"sendTo": "={{ $('\ud83d\ude80 Start here!').item.json['Your email address please'] }}",
"message": "={{ $json.html }}",
"options": {},
"subject": "={{ $json.subject }}"
},
"typeVersion": 2.1
},
{
"id": "293d5e4a-abb4-4657-8291-62def169bdd0",
"name": "Get Orgchart Info",
"type": "n8n-nodes-keephub.keephub",
"onError": "continueRegularOutput",
"position": [
1744,
1296
],
"parameters": {
"nodeId": "={{ $json.orgunits[0] }}",
"resource": "orgchart"
},
"credentials": {
"keephubBearerApi": {
"name": "<your credential>"
}
},
"typeVersion": 1
},
{
"id": "7da3b2b3-4ed6-45fc-b449-f89db55f6255",
"name": "Find form submissions by form",
"type": "n8n-nodes-keephub.keephub",
"position": [
848,
1296
],
"parameters": {
"options": {},
"resource": "formSubmission",
"operation": "findByForm",
"contentRef": "={{ $json['Form ID (copy from the form URL)'] }}",
"authentication": "loginCredentials"
},
"credentials": {
"keephubLoginApi": {
"name": "<your credential>"
}
},
"typeVersion": 1
},
{
"id": "dffed0d2-c867-47e4-b485-7ad7c90cdf99",
"name": "\ud83d\udcca Aggregate Stats & Build Report",
"type": "n8n-nodes-base.code",
"position": [
2192,
1296
],
"parameters": {
"jsCode": "const allItems = $input.all();\n\n// Guard: no submissions \u2014 return a helpful empty report\nif (!allItems.length) {\n const d = new Date().toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });\n return [{ json: { html: '<p>No submissions found for this form. Verify the Form ID and ensure the form has at least one response.</p>', subject: '\ud83d\udcca Competition Report \u2014 ' + d + ' \u2014 No submissions found' } }];\n}\n\nconst safeNum = v => (v == null || isNaN(Number(v))) ? null : Number(v);\n\nfunction formatDuration(s) {\n if (s == null) return '\u2014';\n s = Math.round(Number(s));\n const d = Math.floor(s / 86400);\n const h = Math.floor((s % 86400) / 3600);\n const m = Math.floor((s % 3600) / 60);\n const sc = s % 60;\n if (d > 0) return `${d}d ${h}h ${m}m`;\n if (h > 0) return `${h}h ${m}m ${sc}s`;\n if (m > 0) return `${m}m ${sc}s`;\n return `${sc}s`;\n}\n\nfunction esc(str) {\n return String(str ?? '')\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"');\n}\n\nfunction fmtDate(dt) {\n if (!dt) return '\u2014';\n const d = new Date(dt);\n if (isNaN(d.getTime())) return '\u2014';\n return d.toLocaleString('en-GB', {\n day: '2-digit', month: 'short', year: 'numeric',\n hour: '2-digit', minute: '2-digit',\n });\n}\n\nfunction median(nums) {\n const a = nums.filter(n => n != null).map(n => Number(n)).sort((x, y) => x - y);\n if (!a.length) return null;\n const mid = Math.floor(a.length / 2);\n return a.length % 2 ? a[mid] : Math.round((a[mid - 1] + a[mid]) / 2);\n}\n\nfunction avg(nums) {\n const a = nums.filter(n => n != null).map(n => Number(n));\n if (!a.length) return null;\n return Math.round(a.reduce((s, v) => s + v, 0) / a.length);\n}\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Normalize submissions\n// userOrgunit is already a resolved name from \ud83d\udd17 Enrich with User Data\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst submissions = allItems.map(i => {\n const j = i.json || {};\n const seconds = safeNum(j.durationSeconds);\n return {\n submissionId: j.submissionId || j._id || null,\n userName: j.userName || 'Unknown',\n userEmail: j.userEmail || '',\n userOrgunit: j.userOrgunit || 'Unknown',\n submittedAt: j.submittedAt || null,\n seconds,\n durationFormatted: j.durationFormatted || formatDuration(seconds),\n };\n});\n\nconst totalSubs = submissions.length;\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Aggregate per participant\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst participantKey = s =>\n (s.userEmail ? s.userEmail.toLowerCase() : `${s.userName}::${s.userOrgunit}`).toLowerCase();\n\nconst pMap = new Map();\nfor (const s of submissions) {\n const key = participantKey(s);\n if (!pMap.has(key)) {\n pMap.set(key, {\n key,\n name: s.userName,\n email: s.userEmail,\n orgunit: s.userOrgunit,\n submissionsCount: 0,\n bestSeconds: null,\n bestSubmittedAt: null,\n _sum: 0,\n _count: 0,\n });\n }\n const p = pMap.get(key);\n p.submissionsCount++;\n if (s.seconds != null) {\n p._sum += s.seconds;\n p._count += 1;\n if (p.bestSeconds == null || s.seconds < p.bestSeconds) {\n p.bestSeconds = s.seconds;\n p.bestSubmittedAt = s.submittedAt;\n }\n }\n p.name = s.userName || p.name;\n p.orgunit = s.userOrgunit || p.orgunit;\n p.email = s.userEmail || p.email;\n}\n\nconst participants = Array.from(pMap.values()).map(p => ({\n ...p,\n avgSeconds: p._count ? Math.round(p._sum / p._count) : null,\n bestFormatted: formatDuration(p.bestSeconds),\n}));\n\nparticipants.sort((a, b) => {\n if (a.bestSeconds == null && b.bestSeconds == null) return 0;\n if (a.bestSeconds == null) return 1;\n if (b.bestSeconds == null) return -1;\n return a.bestSeconds - b.bestSeconds;\n});\n\nconst participantsCount = participants.length;\nconst fastest = participants[0] || null;\nconst slowest = participants[participants.length - 1] || null;\nconst bestTimes = participants.map(p => p.bestSeconds).filter(v => v != null);\nconst avgBestSeconds = avg(bestTimes);\nconst medianBestSeconds = median(bestTimes);\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Org unit aggregation\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst ouMap = new Map();\nfor (const p of participants) {\n if (p.bestSeconds == null) continue;\n const ou = p.orgunit || 'Unknown';\n if (!ouMap.has(ou)) ouMap.set(ou, { name: ou, total: 0, count: 0 });\n const d = ouMap.get(ou);\n d.total += p.bestSeconds;\n d.count += 1;\n}\n\nconst orgunits = Array.from(ouMap.values())\n .map(x => ({\n name: x.name,\n avgSeconds: x.count ? Math.round(x.total / x.count) : null,\n avgFormatted: formatDuration(x.count ? Math.round(x.total / x.count) : null),\n count: x.count,\n }))\n .sort((a, b) => (a.avgSeconds ?? 9e15) - (b.avgSeconds ?? 9e15))\n .slice(0, 15);\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Bar chart helpers\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction barRow({ left, right, pct, color, muted, emphasis }) {\n const leftTxt = esc(left || '\u2014');\n const rightTxt = esc(right || '\u2014');\n const labelColor = muted ? '#6b7280' : '#111827';\n const rightColor = emphasis ? '#4f46e5' : '#6b7280';\n const weight = emphasis ? '800' : '600';\n const bgFallback = color.includes('linear-gradient') ? 'rgba(99,102,241,0.85)' : color;\n return `\n <tr>\n <td align=\"right\" valign=\"middle\" style=\"padding:6px 12px 6px 0;white-space:nowrap;color:${labelColor};font-size:13px;font-weight:${weight};width:180px\">${leftTxt}</td>\n <td valign=\"middle\" style=\"padding:6px 0;width:100%\">\n <table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"border-collapse:separate\">\n <tr>\n <td style=\"background:#eef2f7;border-radius:999px;height:12px;line-height:12px;font-size:0;overflow:hidden\">\n <div style=\"display:block;background-color:${bgFallback};background:${color};height:12px;width:${pct}%;min-width:2px;border-radius:999px\"></div>\n </td>\n </tr>\n </table>\n </td>\n <td align=\"right\" valign=\"middle\" style=\"padding:6px 0 6px 12px;white-space:nowrap;color:${rightColor};font-size:13px;font-weight:800;width:80px\">${rightTxt}</td>\n </tr>\n `;\n}\n\nfunction buildParticipantsBarChart(rows) {\n const max = Math.max(...rows.map(r => r.bestSeconds || 0), 1);\n const body = rows.map((p, i) => barRow({\n left: p.name.length > 22 ? p.name.slice(0, 22) + '\u2026' : p.name,\n right: p.bestFormatted,\n pct: Math.round(((p.bestSeconds || 0) / max) * 100),\n color: i < 3\n ? 'linear-gradient(90deg, rgba(99,102,241,0.95), rgba(139,92,246,0.95))'\n : 'rgba(99,102,241,0.75)',\n muted: false,\n emphasis: i < 3,\n })).join('');\n return `<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"border-collapse:collapse\">${body}</table>`;\n}\n\nfunction buildOrgunitBarChart(rows) {\n const max = Math.max(...rows.map(r => r.avgSeconds || 0), 1);\n const body = rows.map((ou, i) => barRow({\n left: ou.name.length > 22 ? ou.name.slice(0, 22) + '\u2026' : ou.name,\n right: `${ou.avgFormatted} \u00b7 ${ou.count}`,\n pct: Math.round(((ou.avgSeconds || 0) / max) * 100),\n color: i === 0\n ? 'linear-gradient(90deg, rgba(16,185,129,0.95), rgba(52,211,153,0.95))'\n : 'rgba(16,185,129,0.75)',\n muted: true,\n emphasis: i === 0,\n })).join('');\n return `<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"border-collapse:collapse\">${body}</table>`;\n}\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Leaderboard rows\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst medals = ['\ud83e\udd47', '\ud83e\udd48', '\ud83e\udd49'];\nconst top10 = participants.slice(0, 10);\n\nconst leaderboardRows = top10.map((p, idx) => {\n const bg = idx % 2 ? '#f9fafb' : '#ffffff';\n const medal = idx < 3 ? medals[idx] : String(idx + 1);\n return `\n <tr style=\"background:${bg}\">\n <td align=\"center\" valign=\"middle\" style=\"padding:10px 12px;color:#6b7280;font-size:13px;font-weight:800;width:40px\">${medal}</td>\n <td valign=\"middle\" style=\"padding:10px 12px;color:#111827;font-size:14px;font-weight:700\">${esc(p.name)}</td>\n <td valign=\"middle\" style=\"padding:10px 12px;color:#6b7280;font-size:13px\">${esc(p.orgunit)}</td>\n <td align=\"right\" valign=\"middle\" style=\"padding:10px 12px\">\n <span style=\"display:inline-block;background:#eef2ff;color:#4f46e5;font-weight:800;border-radius:999px;padding:4px 10px;font-size:13px;white-space:nowrap\">${esc(p.bestFormatted)}</span>\n </td>\n <td align=\"right\" valign=\"middle\" style=\"padding:10px 12px;color:#9ca3af;font-size:12px;white-space:nowrap\">${fmtDate(p.bestSubmittedAt)}</td>\n <td align=\"right\" valign=\"middle\" style=\"padding:10px 12px;color:#9ca3af;font-size:12px;white-space:nowrap\">${p.submissionsCount}</td>\n </tr>\n `;\n}).join('');\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// HTML\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Reference time label for the report header\nconst formData = $('\ud83d\ude80 Start here!').first().json;\nconst dateRaw = formData['Competition start date (optional)'] || '';\nconst timeRaw = formData['Competition start time (optional)'] || '';\nconst customStartRaw = dateRaw ? (dateRaw + (timeRaw ? 'T' + timeRaw : 'T00:00')) : '';\nconst customStartDt = customStartRaw ? new Date(customStartRaw) : null;\nconst refTimeLabel = (customStartDt && !isNaN(customStartDt.getTime()))\n ? 'from ' + customStartDt.toLocaleString('en-GB', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })\n : 'from form creation (Keephub default)';\n\nconst weekStr = new Date().toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });\nconst fastestNamePlain = fastest?.name || '\u2014';\nconst fastestTimePlain = fastest?.bestSeconds != null ? formatDuration(fastest.bestSeconds) : '\u2014';\nconst safeFastestNameHtml = esc(fastestNamePlain);\nconst safeFastestTimeHtml = esc(fastestTimePlain);\nconst kpiMinHeight = 138;\nconst kpiBottomSpacer = 18;\n\nconst html = `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n <meta name=\"color-scheme\" content=\"light dark\">\n <meta name=\"supported-color-schemes\" content=\"light dark\">\n <title>Form Response Competition</title>\n</head>\n<body style=\"margin:0;padding:0;background:#f3f4f6;\">\n <table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"background:#f3f4f6;border-collapse:collapse\">\n <tr>\n <td align=\"center\" valign=\"top\" style=\"padding:24px 12px;\">\n <table role=\"presentation\" width=\"700\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"width:100%;max-width:700px;border-collapse:collapse\">\n\n <!-- Header -->\n <tr>\n <td style=\"padding:0;\">\n <table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"border-collapse:separate;background:linear-gradient(135deg,#4f46e5 0%,#7c3aed 50%,#9333ea 100%);border-radius:16px;\">\n <tr>\n <td align=\"center\" style=\"padding:34px 24px;\">\n <div style=\"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;color:rgba(255,255,255,0.8);font-size:12px;letter-spacing:2px;text-transform:uppercase;font-weight:700;margin-bottom:10px;\">Competition Report</div>\n <div style=\"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;color:#ffffff;font-size:28px;font-weight:900;letter-spacing:-0.5px;line-height:1.15;\">Form Response Competition</div>\n <div style=\"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;color:rgba(255,255,255,0.8);font-size:14px;font-weight:600;margin-top:10px;\">${esc(weekStr)}</div>\n <div style=\"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;color:rgba(255,255,255,0.6);font-size:12px;font-weight:400;margin-top:6px;\">Response times measured ${refTimeLabel}</div>\n </td>\n </tr>\n </table>\n </td>\n </tr>\n\n <tr><td style=\"height:16px;line-height:16px;font-size:0;\"> </td></tr>\n\n <!-- KPI Cards -->\n <tr>\n <td>\n <table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"border-collapse:separate\">\n <tr>\n <td valign=\"top\" width=\"33.33%\" style=\"padding:0 6px 0 0;\">\n <table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"background:#ffffff;border-radius:14px;border-top:4px solid #f59e0b;box-shadow:0 2px 10px rgba(17,24,39,0.06);\">\n <tr>\n <td style=\"padding:18px 16px;height:${kpiMinHeight}px;vertical-align:top;\">\n <div style=\"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;color:#9ca3af;font-size:10px;letter-spacing:1.6px;text-transform:uppercase;font-weight:800;\">Fastest</div>\n <div style=\"height:6px;line-height:6px;font-size:0;\"> </div>\n <div style=\"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;color:#111827;font-size:16px;font-weight:900;line-height:1.2;\">${safeFastestNameHtml}</div>\n <div style=\"height:6px;line-height:6px;font-size:0;\"> </div>\n <div style=\"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;color:#f59e0b;font-size:26px;font-weight:900;line-height:1;\">${safeFastestTimeHtml}</div>\n <div style=\"margin-top:6px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;color:#9ca3af;font-size:11px;\">Best time</div>\n <div style=\"height:${kpiBottomSpacer}px;line-height:${kpiBottomSpacer}px;font-size:0;\"> </div>\n </td>\n </tr>\n </table>\n </td>\n <td valign=\"top\" width=\"33.33%\" style=\"padding:0 3px;\">\n <table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"background:#ffffff;border-radius:14px;border-top:4px solid #10b981;box-shadow:0 2px 10px rgba(17,24,39,0.06);\">\n <tr>\n <td style=\"padding:18px 16px;height:${kpiMinHeight}px;vertical-align:top;\">\n <div style=\"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;color:#9ca3af;font-size:10px;letter-spacing:1.6px;text-transform:uppercase;font-weight:800;\">Participants</div>\n <div style=\"height:6px;line-height:6px;font-size:0;\"> </div>\n <div style=\"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;color:#111827;font-size:34px;font-weight:900;line-height:1;\">${participantsCount}</div>\n <div style=\"margin-top:6px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;color:#6b7280;font-size:12px;\">from ${orgunits.length || 1} org unit${(orgunits.length || 1) === 1 ? '' : 's'}</div>\n <div style=\"margin-top:6px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;color:#9ca3af;font-size:11px;\">${totalSubs} total submission${totalSubs === 1 ? '' : 's'}</div>\n <div style=\"height:${kpiBottomSpacer}px;line-height:${kpiBottomSpacer}px;font-size:0;\"> </div>\n </td>\n </tr>\n </table>\n </td>\n <td valign=\"top\" width=\"33.33%\" style=\"padding:0 0 0 6px;\">\n <table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"background:#ffffff;border-radius:14px;border-top:4px solid #6366f1;box-shadow:0 2px 10px rgba(17,24,39,0.06);\">\n <tr>\n <td style=\"padding:18px 16px;height:${kpiMinHeight}px;vertical-align:top;\">\n <div style=\"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;color:#9ca3af;font-size:10px;letter-spacing:1.6px;text-transform:uppercase;font-weight:800;\">Typical best time</div>\n <div style=\"height:6px;line-height:6px;font-size:0;\"> </div>\n <div style=\"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;color:#111827;font-size:26px;font-weight:900;line-height:1;\">${esc(formatDuration(avgBestSeconds))}</div>\n <div style=\"margin-top:6px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;color:#9ca3af;font-size:11px;\">Median: <span style=\"color:#6366f1;font-weight:900;\">${esc(formatDuration(medianBestSeconds))}</span></div>\n <div style=\"margin-top:2px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;color:#9ca3af;font-size:11px;\">Slowest: <span style=\"color:#ef4444;font-weight:900;\">${esc(formatDuration(slowest?.bestSeconds))}</span></div>\n <div style=\"height:${kpiBottomSpacer}px;line-height:${kpiBottomSpacer}px;font-size:0;\"> </div>\n </td>\n </tr>\n </table>\n </td>\n </tr>\n </table>\n </td>\n </tr>\n\n <tr><td style=\"height:18px;line-height:18px;font-size:0;\"> </td></tr>\n\n <!-- Top performers -->\n <tr>\n <td>\n <table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"background:#ffffff;border-radius:14px;overflow:hidden;box-shadow:0 2px 10px rgba(17,24,39,0.06);\">\n <tr>\n <td style=\"padding:20px 20px 10px;\">\n <div style=\"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;color:#111827;font-size:16px;font-weight:900;\">Top performers</div>\n <div style=\"margin-top:4px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;color:#9ca3af;font-size:13px;\">Ranked by each participant's best response time</div>\n </td>\n </tr>\n <tr>\n <td style=\"padding:6px 20px 14px;\">${buildParticipantsBarChart(top10)}</td>\n </tr>\n <tr>\n <td style=\"padding:0;\">\n <table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"border-collapse:collapse\">\n <tr style=\"background:#f9fafb;border-top:1px solid #e5e7eb;border-bottom:1px solid #e5e7eb\">\n <td style=\"padding:10px 12px;color:#6b7280;font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:1px;\" align=\"center\">#</td>\n <td style=\"padding:10px 12px;color:#6b7280;font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:1px;\">Participant</td>\n <td style=\"padding:10px 12px;color:#6b7280;font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:1px;\">Org unit</td>\n <td style=\"padding:10px 12px;color:#6b7280;font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:1px;\" align=\"right\">Best</td>\n <td style=\"padding:10px 12px;color:#6b7280;font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:1px;\" align=\"right\">Submitted</td>\n <td style=\"padding:10px 12px;color:#6b7280;font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:1px;\" align=\"right\">Subs</td>\n </tr>\n ${leaderboardRows}\n </table>\n </td>\n </tr>\n </table>\n </td>\n </tr>\n\n <tr><td style=\"height:18px;line-height:18px;font-size:0;\"> </td></tr>\n\n <!-- Org units -->\n ${orgunits.length ? `\n <tr>\n <td>\n <table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"background:#ffffff;border-radius:14px;overflow:hidden;box-shadow:0 2px 10px rgba(17,24,39,0.06);\">\n <tr>\n <td style=\"padding:20px 20px 10px;\">\n <div style=\"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;color:#111827;font-size:16px;font-weight:900;\">Org unit performance</div>\n <div style=\"margin-top:4px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;color:#9ca3af;font-size:13px;\">Average of participant best times per org unit</div>\n </td>\n </tr>\n <tr>\n <td style=\"padding:6px 20px 18px;\">${buildOrgunitBarChart(orgunits)}</td>\n </tr>\n </table>\n </td>\n </tr>\n <tr><td style=\"height:18px;line-height:18px;font-size:0;\"> </td></tr>\n ` : ''}\n\n <!-- Footer -->\n <tr>\n <td align=\"center\" style=\"padding:8px 6px 20px;\">\n <div style=\"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;color:#cbd5e1;font-size:11px;\">\n Generated automatically by <span style=\"color:#94a3b8;font-weight:800;\">n8n</span> \u00b7 Powered by <span style=\"color:#94a3b8;font-weight:800;\">Keephub</span>\n </div>\n </td>\n </tr>\n\n </table>\n </td>\n </tr>\n </table>\n</body>\n</html>`;\n\n const subject = `\ud83d\udcca Competition Report \u2014 ${weekStr} \u00b7 \ud83c\udfc6 ${fastestNamePlain} in ${fastestTimePlain} \u00b7 ${participantsCount} participant${participantsCount === 1 ? '' : 's'}`; \n\n return [{ json: { html, subject } }];"
},
"typeVersion": 2
},
{
"id": "79c8f4c6-2185-4c0f-824a-1a4f4d07f4cd",
"name": "\ud83d\udd17 Enrich with User Data",
"type": "n8n-nodes-base.code",
"position": [
1968,
1296
],
"parameters": {
"jsCode": "// Guard: bail out early if no enrichment data available\nconst orgItems = $input.all();\nif (!orgItems.length) return [];\n\nconst userItems = $('Get submitter details for a form submission').all();\nconst durationItems = $('Calculate response duration for a form submission').all();\nconst splitItems = $('\u2702\ufe0f Split into Items').all();\n\n// Check if a custom competition start date/time was provided on the form\nconst formData = $('\ud83d\ude80 Start here!').first().json;\nconst dateRaw = formData['Competition start date (optional)'] || '';\nconst timeRaw = formData['Competition start time (optional)'] || '';\nconst customStartRaw = dateRaw ? (dateRaw + (timeRaw ? 'T' + timeRaw : 'T00:00')) : '';\nconst customStartDate = customStartRaw ? new Date(customStartRaw) : null;\nconst useCustomStart = customStartDate && !isNaN(customStartDate.getTime());\n\n// Direct id -> name map from all orgchart nodes\nconst orgById = new Map();\nfor (const o of orgItems) {\n const n = o.json;\n if (!n?._id) continue;\n const id = String(n._id);\n if (!orgById.has(id)) {\n orgById.set(id, String(n.name || id).trim());\n }\n}\n\nfunction resolveOrgunit(orgunits) {\n if (!Array.isArray(orgunits) || orgunits.length === 0) return 'Unknown';\n // Try each orgunit the user belongs to \u2014 return the first one we have a name for\n // Prefer non-root nodes (more specific), so skip root#### if a better match exists\n const nonRoot = orgunits.find(id => !String(id).startsWith('root') && orgById.has(String(id)));\n if (nonRoot) return orgById.get(String(nonRoot));\n const any = orgunits.find(id => orgById.has(String(id)));\n if (any) return orgById.get(String(any));\n return String(orgunits[0]) || 'Unknown';\n}\n\nfunction parseDuration(str) {\n if (!str) return null;\n const d = parseInt(str.match(/(\\d+)d/)?.[1] || 0);\n const h = parseInt(str.match(/(\\d+)h/)?.[1] || 0);\n const m = parseInt(str.match(/(\\d+)m/)?.[1] || 0);\n const s = parseInt(str.match(/(\\d+)s/)?.[1] || 0);\n return d * 86400 + h * 3600 + m * 60 + s;\n}\n\nfunction formatDuration(totalSeconds) {\n if (totalSeconds == null) return '\u2014';\n totalSeconds = Math.round(totalSeconds);\n const d = Math.floor(totalSeconds / 86400);\n const h = Math.floor((totalSeconds % 86400) / 3600);\n const m = Math.floor((totalSeconds % 3600) / 60);\n const s = totalSeconds % 60;\n if (d > 0) return `${d}d ${h}h ${m}m`;\n if (h > 0) return `${h}h ${m}m ${s}s`;\n if (m > 0) return `${m}m ${s}s`;\n return `${s}s`;\n}\n\nconst count = Math.max(userItems.length, durationItems.length, splitItems.length);\nconst results = [];\n\nfor (let i = 0; i < count; i++) {\n const user = userItems[i]?.json || {};\n const dur = durationItems[i]?.json || {};\n const raw = splitItems[i]?.json || {};\n\n const submittedAt = dur.submittedAt || raw.createdAt || null;\n\n let durationSeconds = null;\n let durationStr = null;\n let durationFormatted = null;\n\n if (useCustomStart && submittedAt) {\n // Custom start provided \u2014 measure from that moment\n const subDate = new Date(submittedAt);\n if (!isNaN(subDate.getTime())) {\n durationSeconds = Math.max(0, Math.round((subDate.getTime() - customStartDate.getTime()) / 1000));\n }\n } else {\n // No custom start \u2014 fall back to Keephub API duration\n durationStr = dur.duration?.timeSinceFormCreated || null;\n durationSeconds = parseDuration(durationStr);\n }\n durationFormatted = formatDuration(durationSeconds);\n\n const fullName =\n user.name ||\n [user.firstName, user.insertion, user.lastName].filter(Boolean).join(' ').trim() ||\n user.loginName ||\n raw.createdBy ||\n 'Unknown';\n\n const userOrgunit = resolveOrgunit(user.orgunits);\n\n results.push({\n json: {\n submissionId: raw._id || null,\n contentRef: raw.contentRef || null,\n submittedAt,\n durationSeconds,\n durationStr,\n durationFormatted,\n userName: fullName,\n userEmail: user.email || '',\n userPosition: user.position || '',\n userLevel: user.level || '',\n userOrgunit,\n }\n });\n}\n\nreturn results;\n"
},
"typeVersion": 2
},
{
"id": "6e2f85d6-bd25-42ed-ade6-0ed783989bc4",
"name": "Get submitter details for a form submission",
"type": "n8n-nodes-keephub.keephub",
"onError": "continueRegularOutput",
"position": [
1520,
1296
],
"parameters": {
"resource": "formSubmission",
"operation": "getSubmitterDetails",
"formSubmissionId": "={{ $('\u2702\ufe0f Split into Items').item.json._id }}"
},
"credentials": {
"keephubBearerApi": {
"name": "<your credential>"
}
},
"typeVersion": 1
},
{
"id": "9010c1ef-e482-4fb3-9bbe-5b8cc530644c",
"name": "Calculate response duration for a form submission",
"type": "n8n-nodes-keephub.keephub",
"onError": "continueRegularOutput",
"position": [
1296,
1296
],
"parameters": {
"resource": "formSubmission",
"operation": "calculateResponseDuration",
"formSubmissionId": "={{ $json._id }}"
},
"credentials": {
"keephubBearerApi": {
"name": "<your credential>"
}
},
"typeVersion": 1
},
{
"id": "eacc15c8-c5f6-4d0f-a294-e8a8e2c4b764",
"name": "\u2702\ufe0f Split into Items",
"type": "n8n-nodes-base.code",
"position": [
1072,
1296
],
"parameters": {
"jsCode": "return $input.all().map(item => ({ json: item.json }));"
},
"typeVersion": 2
},
{
"id": "9f74c3f3-6639-4bee-9837-3a302429f095",
"name": "Overview",
"type": "n8n-nodes-base.stickyNote",
"position": [
576,
688
],
"parameters": {
"color": 1,
"width": 1988,
"height": 788,
"content": "## \ud83c\udfc6 Gamify form response times and email a ranked leaderboard with Keephub\n\nTurn any Keephub form into a timed competition. Paste in a Form ID, and this workflow fetches all submissions, ranks participants by response speed, enriches results with org-unit data, and emails you a polished HTML leaderboard \u2014 fully automated.\n\n**Who it's for:** HR teams, L&D managers, or anyone running timed Keephub form challenges who wants an instant, shareable results report.\n\n## How it works\n1. \ud83d\udccb Someone submits the start form with their email, a Keephub Form ID, and an optional competition start date and time\n2. \u23f1\ufe0f The workflow fetches all submissions and calculates each response time (from the custom start if provided, otherwise Keephub's default)\n3. \ud83d\udc64 Each result is enriched with the user's name and org unit from the Keephub orgchart\n4. \ud83d\udce7 A ranked leaderboard (top 10 + org-unit breakdown) is built and emailed as a beautiful HTML report\n\n## Setup steps\n1. \ud83d\udce6 Install **n8n-nodes-keephub** verified node (v1.5+) from Settings\n2. \ud83d\udd11 Connect your Keephub Bearer token or Keephub Login credential in any Keephub node\n3. \ud83d\udcec Connect your Gmail (or SMTP) account in the Send Competition Report node\n4. \ud83d\udd17 Find your Form ID from the Keephub form URL\n5. \u25b6\ufe0f Click **Test workflow**, fill in the n8n form, and check your inbox"
},
"typeVersion": 1
},
{
"id": "016cb277-b787-40ad-babc-0ea907b830bb",
"name": "Fetch submissions",
"type": "n8n-nodes-base.stickyNote",
"position": [
576,
1168
],
"parameters": {
"color": 7,
"width": 640,
"height": 310,
"content": "## \ud83d\udce5 Fetch submissions\nCollects all form submissions from Keephub for the given Form ID and splits them into individual items for per-user processing."
},
"typeVersion": 1
},
{
"id": "667886ce-d343-4875-82b5-ac63ba054b71",
"name": "Process and enrich",
"type": "n8n-nodes-base.stickyNote",
"position": [
1232,
1168
],
"parameters": {
"color": 7,
"width": 886,
"height": 310,
"content": "## \u2699\ufe0f Process & enrich\nCalculates response duration per submission \u2014 using the custom start time when provided, or Keephub\u2019s default (time since form creation). Fetches submitter details and resolves org-unit names from the orgchart. Nodes continue on error so one bad record won\u2019t stop the report."
},
"typeVersion": 1
},
{
"id": "ac3f949a-fc38-47aa-b72b-3c80923648d7",
"name": "Build and send report",
"type": "n8n-nodes-base.stickyNote",
"position": [
2144,
1168
],
"parameters": {
"color": 7,
"width": 420,
"height": 310,
"content": "## \ud83d\udcca Build & send\nAggregates stats, builds a ranked HTML leaderboard with org-unit breakdown, and emails the competition report to the requester."
},
"typeVersion": 1
},
{
"id": "06dca842-339b-48e8-b8b3-d4e32133ffe6",
"name": "\ud83d\ude80 Start here!",
"type": "n8n-nodes-base.formTrigger",
"position": [
624,
1296
],
"parameters": {
"options": {
"customCss": "@import url('https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;600&display=swap');\n\n* {\n font-family: 'Open Sans', sans-serif;\n box-sizing: border-box;\n}\n\nbody {\n background: #f0eeff;\n margin: 0;\n padding: 0;\n}\n\n.container {\n min-height: 100vh;\n padding: 32px 16px;\n display: flex;\n justify-content: center;\n align-items: flex-start;\n}\n\nsection {\n width: 100%;\n max-width: 448px;\n}\n\n.test-notice {\n background: #fefaf6;\n border: 1px solid #f6dcb7;\n border-radius: 8px;\n padding: 10px 16px;\n margin-bottom: 12px;\n text-align: center;\n}\n\n.test-notice p {\n color: #e6a23d;\n font-size: 12px;\n font-weight: 600;\n margin: 0;\n}\n\nhr {\n border: none;\n border-top: 1px solid #dbdfe7;\n margin: 12px 0;\n}\n\n.card {\n background: #ffffff;\n border-radius: 12px;\n box-shadow: 0px 4px 24px 0px rgba(99, 77, 255, 0.12);\n overflow: hidden;\n margin-bottom: 16px;\n border: none;\n padding: 0;\n}\n\n.form-header {\n background: linear-gradient(135deg, #634dff 0%, #7c3aed 100%);\n padding: 28px 24px;\n margin-bottom: 0;\n border-bottom: none;\n}\n\n.form-header h1 {\n font-size: 22px;\n font-weight: 600;\n color: #ffffff;\n margin: 0 0 8px 0;\n line-height: 1.3;\n}\n\n.form-header p {\n font-size: 13px;\n font-weight: 400;\n color: rgba(255, 255, 255, 0.8);\n margin: 0;\n line-height: 1.6;\n}\n\n.inputs-wrapper {\n padding: 24px 24px 8px 24px;\n}\n\n.form-group {\n margin-bottom: 18px;\n}\n\n.form-label {\n display: block;\n font-size: 13px;\n font-weight: 600;\n color: #525356;\n margin-bottom: 6px;\n}\n\n.form-label.form-required::after {\n content: ' *';\n color: #ff6d5a;\n}\n\n.form-input {\n display: block;\n width: 100%;\n padding: 11px 14px;\n font-size: 14px;\n font-weight: 400;\n color: #525356;\n background: #fbfcfe;\n border: 1px solid #dbdfe7;\n border-radius: 8px;\n outline: none;\n transition: border-color 0.15s ease, box-shadow 0.15s ease;\n}\n\n.form-input:focus {\n border-color: #634dff;\n background: #ffffff;\n box-shadow: 0 0 0 3px rgba(99, 77, 255, 0.1);\n}\n\n.form-input::placeholder {\n color: #a0a3a8;\n}\n\n[class^=\"error-\"] {\n font-size: 12px;\n color: #ea1f30;\n margin: 4px 0 0 0;\n display: none;\n}\n\n[class^=\"error-\"].error-show {\n display: block;\n}\n\n#submit-btn {\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 8px;\n width: calc(100% - 48px);\n margin: 0 24px 24px 24px;\n height: 48px;\n background: #ff6d5a;\n color: #ffffff;\n border: none;\n border-radius: 8px;\n font-size: 14px;\n font-weight: 600;\n cursor: pointer;\n transition: opacity 0.15s ease, transform 0.1s ease;\n}\n\n#submit-btn:hover {\n opacity: 0.9;\n transform: translateY(-1px);\n}\n\n#submit-btn:active {\n opacity: 1;\n transform: translateY(0);\n}\n\n#submit-btn:disabled {\n opacity: 0.6;\n cursor: not-allowed;\n transform: none;\n}\n\n#submit-btn span {\n display: none;\n}\n\n#submit-btn svg {\n fill: #ffffff;\n}\n\n.n8n-link {\n text-align: center;\n margin-top: 16px;\n}\n\n.n8n-link a {\n font-size: 12px;\n color: #7e8186;\n text-decoration: none;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n}\n",
"respondWithOptions": {
"values": {
"formSubmittedText": "Processing... Please check your email shortly!"
}
}
},
"formTitle": "Run a form response time competition with Keephub",
"formFields": {
"values": [
{
"fieldLabel": "Your email address please",
"placeholder": "your@email.com",
"requiredField": true
},
{
"fieldLabel": "Form ID (copy from the form URL)",
"placeholder": "699848533ab62d9d50409890",
"requiredField": true
},
{
"fieldType": "date",
"fieldLabel": "Competition start date (optional)"
},
{
"fieldLabel": "Competition start time (optional)",
"placeholder": "12:35"
}
]
},
"responseMode": "lastNode",
"formDescription": "Enter your email and a Keephub Form ID to receive a detailed competition report.\n\nOptionally provide a competition start date & time \u2014 response durations will be measured from that moment. Leave blank to use Keephub\u2019s default (time since form creation).\n\nThe workflow fetches all submissions, calculates response times, enriches each result with org-unit data, and emails you a ranked leaderboard."
},
"notesInFlow": false,
"typeVersion": 2.5
}
],
"connections": {
"\ud83d\ude80 Start here!": {
"main": [
[
{
"node": "Find form submissions by form",
"type": "main",
"index": 0
}
]
]
},
"Get Orgchart Info": {
"main": [
[
{
"node": "\ud83d\udd17 Enrich with User Data",
"type": "main",
"index": 0
}
]
]
},
"\u2702\ufe0f Split into Items": {
"main": [
[
{
"node": "Calculate response duration for a form submission",
"type": "main",
"index": 0
}
]
]
},
"\ud83d\udd17 Enrich with User Data": {
"main": [
[
{
"node": "\ud83d\udcca Aggregate Stats & Build Report",
"type": "main",
"index": 0
}
]
]
},
"Find form submissions by form": {
"main": [
[
{
"node": "\u2702\ufe0f Split into Items",
"type": "main",
"index": 0
}
]
]
},
"\ud83d\udcca Aggregate Stats & Build Report": {
"main": [
[
{
"node": "\ud83d\udce7 Send Competition Report",
"type": "main",
"index": 0
}
]
]
},
"Get submitter details for a form submission": {
"main": [
[
{
"node": "Get Orgchart Info",
"type": "main",
"index": 0
}
]
]
},
"Calculate response duration for a form submission": {
"main": [
[
{
"node": "Get submitter details for a form submission",
"type": "main",
"index": 0
}
]
]
}
}
}
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
keephubBearerApikeephubLoginApi
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
HR teams, internal comms managers, and operations leads using Keephub who want to turn form completions into a friendly competition and drive faster engagement across the organisation.
Source: https://n8n.io/workflows/13555/ — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
This n8n automation captures new leads from a form submission and automatically:
If you have a form where potential leads reach out, then you probably want to analyze those leads and send a notification if certain requirements are met, e.g. employee number is high enough. MadKudu
行銷自動化:Leads Magnet Mail List - n8n form. Uses formTrigger, googleSheets, httpRequest, gmail. Event-driven trigger; 10 nodes.
Product Introduction: You can create a form on n8n through which you can collect leads from interested user's.
This LinkedIn automation workflow monitors post comments for specific trigger words and automatically sends direct messages with lead magnets to engaged users. The system checks connection status, han