If you're building an LMS, corporate training platform, microlearning app, or certification platform — you already know the ops burden: trial users who never activate, learner engagement that tanks mid-course, content uploads that break the pipeline, renewals that slip through the cracks.
n8n is a self-hostable workflow automation platform that lets your ops and engineering teams wire these together. Because it runs on your own server, learner PII, course content, and customer contract data never leave your infrastructure — which matters when your enterprise customers ask about your data handling in vendor security reviews.
Here are 5 workflows EdTech SaaS ops teams can implement this week. Full importable JSON included.
1. Trial-to-Paid Conversion Drip Sequence
The problem: Trial users sign up, poke around, and disappear. Manual follow-up doesn't scale beyond 50 trials/month.
The workflow:
- Trigger: Webhook (trial_started event from your app)
- Gmail: Day 1 — immediate welcome + quick-start video link + getting-started checklist
- Wait 3 days
- Gmail: Day 4 — use-case email tailored to their industry (corporate_training vs k12 vs higher_ed field from signup)
- Wait 4 days
- Gmail: Day 8 — social proof (case study from a similar customer)
- Wait 3 days
- Gmail: Day 11 — trial ending soon + limited-time upgrade offer
- Sheets: log each touchpoint with sent_at timestamp
{
"nodes": [
{"type": "n8n-nodes-base.webhook", "parameters": {"httpMethod": "POST", "path": "trial-started"}},
{"type": "n8n-nodes-base.gmail", "parameters": {"subject": "=Welcome to {{$json.product_name}}, {{$json.first_name}}!", "message": "=Hi {{$json.first_name}},\n\nYou're in. Here's how to get your first course live in 15 minutes: [link]\n\nQuick-start checklist: [link]\n\nReply to this email anytime — I read every one."}},
{"type": "n8n-nodes-base.wait", "parameters": {"unit": "days", "amount": 3}},
{"type": "n8n-nodes-base.gmail", "parameters": {"subject": "How {{$json.industry}} teams use {{$json.product_name}}", "message": "=Hi {{$json.first_name}},\n\nHere's how a company like yours uses the platform: [case study link by industry]"}},
{"type": "n8n-nodes-base.wait", "parameters": {"unit": "days", "amount": 4}},
{"type": "n8n-nodes-base.gmail", "parameters": {"subject": "What our customers say", "message": "\"We cut onboarding time by 60%\" — [Customer Name], L&D Manager"}},
{"type": "n8n-nodes-base.wait", "parameters": {"unit": "days", "amount": 3}},
{"type": "n8n-nodes-base.gmail", "parameters": {"subject": "=Your trial ends in 3 days, {{$json.first_name}}", "message": "=Upgrade before {{$json.trial_end_date}} and get 20% off your first 3 months: [upgrade link]"}},
{"type": "n8n-nodes-base.googleSheets", "parameters": {"operation": "appendRow", "sheetId": "YOUR_TRIALS_SHEET", "row": {"email": "={{$json.email}}", "sequence_completed": "true", "completed_at": "={{$now}}"}}}
]
}
2. Course Completion & Certificate Delivery Automation
The problem: Learners complete a course and wait 24 hours for a certificate email. Some never get it because the manual process breaks.
The workflow:
- Trigger: Webhook (course.completed event from your LMS backend)
- Code node: extract learner name, course name, completion date, score
- Code node: build HTML certificate (inline CSS, learner name in large font, course name, date, signature block)
- Gmail: send certificate as HTML attachment immediately
- Sheets: log completion (learner_id, course_id, completed_at, score, cert_sent)
- Slack: notify #learning-ops for high-score completions (≥90%) — upsell signal
{
"nodes": [
{"type": "n8n-nodes-base.webhook", "parameters": {"httpMethod": "POST", "path": "course-completed", "responseMode": "responseNode"}},
{"type": "n8n-nodes-base.respondToWebhook", "parameters": {"respondWith": "json", "responseBody": "{\"status\": \"received\"}"}},
{"type": "n8n-nodes-base.code", "parameters": {"jsCode": "const d = $input.first().json;\nconst cert = `<html><body style='font-family:serif;text-align:center;padding:60px'><h1>Certificate of Completion</h1><h2>${d.learner_name}</h2><p>has successfully completed</p><h3>${d.course_name}</h3><p>Score: ${d.score}% | Date: ${new Date(d.completed_at).toLocaleDateString()}</p></body></html>`;\nreturn [{json: {...d, cert_html: cert, is_high_score: parseFloat(d.score) >= 90}}]"}},
{"type": "n8n-nodes-base.gmail", "parameters": {"subject": "=Your certificate: {{$json.course_name}}", "message": "=Congratulations {{$json.learner_name}}! Your certificate is attached.", "additionalFields": {"attachments": [{"name": "certificate.html", "data": "={{$json.cert_html}}", "type": "text/html"}]}}},
{"type": "n8n-nodes-base.googleSheets", "parameters": {"operation": "appendRow", "sheetId": "YOUR_COMPLETIONS_SHEET", "row": {"learner_id": "={{$json.learner_id}}", "course_id": "={{$json.course_id}}", "score": "={{$json.score}}", "cert_sent": "true"}}},
{"type": "n8n-nodes-base.if", "parameters": {"conditions": {"boolean": [{"value1": "={{$json.is_high_score}}", "value2": true}]}}},
{"type": "n8n-nodes-base.slack", "parameters": {"channel": "#learning-ops", "text": "=High score alert: {{$json.learner_name}} scored {{$json.score}}% on {{$json.course_name}} — upsell opportunity"}}
]
}
3. Learner Engagement Alert & Re-Engagement Pipeline
The problem: Corporate training customers pay per seat. When learners stop engaging, the renewal conversation gets hard.
The workflow:
- Schedule: daily 8 AM weekdays
- Postgres: query learner_activity table — filter accounts where avg_days_since_login > 14 for >20% of seats
- Code node: classify accounts — DISENGAGED (>21 days), AT_RISK (14-21 days)
- IF DISENGAGED: Slack to #cs-urgent + draft re-engagement email for CSM to send
- IF AT_RISK: Slack to #cs-watch-list (monitor only)
- Sheets: log each alert with timestamp (dedup: only alert once per account per week)
{
"nodes": [
{"type": "n8n-nodes-base.scheduleTrigger", "parameters": {"rule": {"interval": [{"field": "cronExpression", "expression": "0 8 * * 1-5"}]}}},
{"type": "n8n-nodes-base.postgres", "parameters": {"operation": "executeQuery", "query": "SELECT account_id, account_name, csm_email, AVG(days_since_login) as avg_inactive, COUNT(*) FILTER (WHERE days_since_login > 14) * 100.0 / COUNT(*) as pct_inactive FROM learner_activity GROUP BY account_id, account_name, csm_email HAVING AVG(days_since_login) > 14 AND COUNT(*) > 5"}},
{"type": "n8n-nodes-base.code", "parameters": {"jsCode": "return items.map(i => ({json: {...i.json, risk_level: i.json.avg_inactive > 21 ? 'DISENGAGED' : 'AT_RISK'}}))"}},
{"type": "n8n-nodes-base.switch", "parameters": {"dataPropertyName": "risk_level", "rules": [{"value": "DISENGAGED"}, {"value": "AT_RISK"}]}},
{"type": "n8n-nodes-base.slack", "parameters": {"channel": "#cs-urgent", "text": "=DISENGAGED: {{$json.account_name}} — {{$json.pct_inactive}}% of seats inactive for 21+ days. CSM: {{$json.csm_email}}"}},
{"type": "n8n-nodes-base.slack", "parameters": {"channel": "#cs-watch-list", "text": "=AT_RISK: {{$json.account_name}} — {{$json.pct_inactive}}% of seats inactive 14-21 days"}}
]
}
4. Content Upload Validator & Ingestion Notifier
The problem: Course authors upload a 2 GB video at 11 PM. Your content pipeline chokes. You find out at 9 AM when they've already emailed support.
The workflow:
- Trigger: Webhook (content.uploaded event from your storage layer)
- Code node: validate file type (mp4/pdf/scorm), file size (<500 MB), duration estimate
- IF valid: Slack #content-ops with uploader name, course, file size, estimated processing time
- IF valid: Gmail to uploader — 'Your content is in the queue, estimated live in X minutes'
- IF invalid: Gmail to uploader — specific error (wrong type / too large / corrupted) + how to fix
- IF invalid: Slack #content-ops with error details for human review
{
"nodes": [
{"type": "n8n-nodes-base.webhook", "parameters": {"httpMethod": "POST", "path": "content-uploaded", "responseMode": "responseNode"}},
{"type": "n8n-nodes-base.respondToWebhook", "parameters": {"respondWith": "json", "responseBody": "{\"status\": \"received\"}"}},
{"type": "n8n-nodes-base.code", "parameters": {"jsCode": "const d = $input.first().json;\nconst allowedTypes = ['video/mp4', 'application/pdf', 'application/zip'];\nconst maxSize = 500 * 1024 * 1024;\nconst isValidType = allowedTypes.includes(d.mime_type);\nconst isValidSize = d.file_size_bytes < maxSize;\nconst errors = [];\nif (!isValidType) errors.push('File type not supported: ' + d.mime_type);\nif (!isValidSize) errors.push('File too large: ' + Math.round(d.file_size_bytes/1024/1024) + 'MB (max 500MB)');\nreturn [{json: {...d, is_valid: errors.length === 0, errors: errors.join(', '), size_mb: Math.round(d.file_size_bytes/1024/1024)}}]"}},
{"type": "n8n-nodes-base.if", "parameters": {"conditions": {"boolean": [{"value1": "={{$json.is_valid}}", "value2": true}]}}},
{"type": "n8n-nodes-base.slack", "parameters": {"channel": "#content-ops", "text": "=New upload queued: {{$json.file_name}} ({{$json.size_mb}}MB) by {{$json.uploader_name}} for course '{{$json.course_name}}'"}},
{"type": "n8n-nodes-base.gmail", "parameters": {"subject": "Your content is processing", "message": "=Hi {{$json.uploader_name}},\n\nYour file '{{$json.file_name}}' is in the processing queue. Estimated time to publish: 10-20 minutes."}},
{"type": "n8n-nodes-base.gmail", "parameters": {"subject": "=Upload issue: {{$json.file_name}}", "message": "=Hi {{$json.uploader_name}},\n\nWe couldn't process your upload. Reason: {{$json.errors}}\n\nPlease check the requirements at [help link] and re-upload."}}
]
}
5. Weekly Platform & Business Metrics Briefing
The problem: Leadership asks for weekly numbers. Someone builds a Sheets dashboard manually every Monday morning.
The workflow:
- Schedule: Monday 8 AM
- Postgres: query — new trials this week, trial-to-paid conversions, courses published, course completions, MAU, churn events
- Code node: compute WoW% change using $getWorkflowStaticData to persist last week's values
- Build HTML email with KPI table (up/down arrows for each metric)
- Gmail: send to leadership BCC list
- Slack: one-liner to #management
{
"nodes": [
{"type": "n8n-nodes-base.scheduleTrigger", "parameters": {"rule": {"interval": [{"field": "cronExpression", "expression": "0 8 * * 1"}]}}},
{"type": "n8n-nodes-base.postgres", "parameters": {"operation": "executeQuery", "query": "SELECT (SELECT COUNT(*) FROM trials WHERE created_at > NOW() - INTERVAL '7 days') as new_trials, (SELECT COUNT(*) FROM subscriptions WHERE created_at > NOW() - INTERVAL '7 days') as conversions, (SELECT COUNT(DISTINCT learner_id) FROM learner_activity WHERE last_login > NOW() - INTERVAL '30 days') as mau, (SELECT COUNT(*) FROM completions WHERE completed_at > NOW() - INTERVAL '7 days') as completions"}},
{"type": "n8n-nodes-base.code", "parameters": {"jsCode": "const d = $input.first().json;\nconst state = $getWorkflowStaticData('global');\nconst prev = state.last_week || {};\nconst pct = (cur, old) => old ? ((cur - old) / old * 100).toFixed(1) + '%' : 'N/A';\nconst arrow = (cur, old) => !old ? '' : cur >= old ? ' ↑' : ' ↓';\nconst row = (label, key) => `<tr><td>${label}</td><td>${d[key]}${arrow(d[key], prev[key])}</td><td>${pct(d[key], prev[key])}</td></tr>`;\nconst html = `<h2>Weekly EdTech Metrics</h2><table border=1 cellpadding=6><tr><th>Metric</th><th>This Week</th><th>WoW</th></tr>${row('New Trials','new_trials')}${row('Conversions','conversions')}${row('MAU','mau')}${row('Completions','completions')}</table>`;\nstate.last_week = d;\n$setWorkflowStaticData('global', state);\nreturn [{json: {...d, html_report: html}}]"}},
{"type": "n8n-nodes-base.gmail", "parameters": {"subject": "=Weekly Platform Metrics — {{$today.format('YYYY-MM-DD')}}", "message": "={{$json.html_report}}", "additionalFields": {"contentType": "html"}}},
{"type": "n8n-nodes-base.slack", "parameters": {"channel": "#management", "text": "=Week in brief: {{$json.new_trials}} trials | {{$json.conversions}} conversions | {{$json.mau}} MAU | {{$json.completions}} completions"}}
]
}
Why self-hosted n8n fits EdTech SaaS
EdTech companies handle data that needs to stay in your infrastructure:
- COPPA — if any learners are under 13, their data cannot route through third-party cloud iPaaS
- FERPA-adjacent — enterprise customers (universities, K-12 districts) ask where student data goes
- GDPR Article 44-46 — EU learner data has cross-border transfer restrictions
- SOC2/ISO 27001 — your enterprise contracts likely already require data residency commitments
Comparison:
| Feature | n8n (self-hosted) | Zapier | Make.com |
|---|---|---|---|
| Learner PII stays in your VPC | Yes | No | No |
| COPPA/FERPA/GDPR compliant ops | Yes | Requires review | Requires review |
| Custom scoring logic | Full JS/Python | Limited | Limited |
| Monthly cost at 50k tasks | $0 | $199+ | $99+ |
| Git-versioned audit trail | Yes (JSON) | No | No |
Get the templates
These workflows are pre-built and importable at stripeai.gumroad.com — individual templates from $12, or the complete FlowKit bundle for $97.
Drop a comment if you're adapting any of these to your EdTech stack.
Top comments (0)