This workflow follows the Airtable → HTTP Request 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 →
{
"name": "02 - Notify (F3)",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 9 * * *"
}
]
}
},
"id": "Schedule 09:00",
"name": "Schedule 09:00",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
-400,
300
],
"notes": "Office tz comes from workflow Settings -> Timezone (Europe/Istanbul)."
},
{
"parameters": {
"resource": "record",
"operation": "search",
"base": {
"__rl": true,
"mode": "id",
"value": "={{ $env.AIRTABLE_BASE_ID }}"
},
"table": {
"__rl": true,
"mode": "name",
"value": "Employees"
},
"filterByFormula": "",
"options": {}
},
"id": "Fetch Employees",
"name": "Fetch Employees",
"type": "n8n-nodes-base.airtable",
"typeVersion": 2.1,
"position": [
-180,
300
],
"executeOnce": true,
"notes": "Attach Airtable credential after import."
},
{
"parameters": {
"resource": "record",
"operation": "search",
"base": {
"__rl": true,
"mode": "id",
"value": "={{ $env.AIRTABLE_BASE_ID }}"
},
"table": {
"__rl": true,
"mode": "name",
"value": "Onboarding_Tasks"
},
"filterByFormula": "AND({Status} = 'Pending', {Notified_At} = BLANK())",
"options": {}
},
"id": "Fetch Pending Tasks",
"name": "Fetch Pending Tasks",
"type": "n8n-nodes-base.airtable",
"typeVersion": 2.1,
"position": [
40,
300
],
"executeOnce": true,
"notes": "Attach Airtable credential after import."
},
{
"parameters": {
"language": "javaScript",
"jsCode": "// ============================================================================\n// GENERATED by `npm run build:nodes` \u2014 DO NOT EDIT.\n// Wrapper: workflows/code-nodes/notify.wrapper.js\n// Core inlined verbatim from: scripts/notify.js\n// Paste this whole file into the n8n Code node.\n// ============================================================================\n\n// n8n Code node \u2014 \"Build Messages\" (F3) (THIN WRAPPER \u2014 do not put logic here)\n//\n// Core (notify.js) is inlined by `npm run build:nodes`. Edit logic in scripts/notify.js.\n// Paste the BUILT file (notify.built.js) into the n8n Code node.\n//\n// Upstream node names (rename the strings below if your workflow differs):\n// input items -> Pending, un-notified Onboarding_Tasks\n// \"Fetch Employees\" -> Employees rows (live name + start date, joined not snapshotted)\n// Env: OFFICE_TIMEZONE.\n//\n// Output: one item per assignee = { assignee_telegram_id, text, reply_markup, task_ids }.\n// Unresolved tasks (empty assignee) are skipped \u2014 there is no one to message; they wait\n// until HR fixes the assignee, then a later run picks them up.\n\n// ---- inlined from scripts/notify.js ----\n// notify.js \u2014 pure logic for F3 (grouped notifications) and F4 (completion).\n//\n// No network, no Telegram, no Date.now(). The only time-dependent input, \"today\", is\n// injected as an ISO date. `officeToday` derives that date in a timezone from a given\n// moment \u2014 kept here so the day-boundary rule is testable, not buried in n8n.\n\n/**\n * The office-local calendar date (ISO YYYY-MM-DD) for a given moment.\n * en-CA formats as YYYY-MM-DD; the timeZone makes it office-local, not UTC \u2014 this is\n * what makes \"Due_Date <= today\" correct regardless of what UTC clock the job runs on.\n * @param {Date} now\n * @param {string} timeZone - e.g. \"Europe/Istanbul\"\n */\nfunction officeToday(now, timeZone) {\n return now.toLocaleDateString('en-CA', { timeZone });\n}\n\n/**\n * Tasks due for a notification today: Pending, due on/before today, not yet notified.\n * Dates are compared as ISO strings (YYYY-MM-DD sorts chronologically), so this is a\n * date comparison, never a moment comparison.\n */\nfunction selectDueTasks(tasks, today) {\n return tasks.filter(\n (t) => t.status === 'Pending' && !t.notified_at && String(t.due_date) <= today,\n );\n}\n\n/**\n * Group tasks by assignee Telegram id, preserving first-seen order \u2014 one message per\n * person, not one per task.\n * @returns {{ assignee_telegram_id: string, tasks: object[] }[]}\n */\nfunction groupByAssignee(tasks) {\n const groups = new Map();\n for (const t of tasks) {\n const key = String(t.assignee_telegram_id);\n if (!groups.has(key)) groups.set(key, []);\n groups.get(key).push(t);\n }\n return [...groups.entries()].map(([assignee_telegram_id, ts]) => ({\n assignee_telegram_id,\n tasks: ts,\n }));\n}\n\n/**\n * Render a grouped message as Telegram payload pieces: `text` plus an inline keyboard.\n * Works for the first send (all Pending) and for re-render after a tap (mixed statuses):\n * final tasks show a marker and lose their buttons; Pending tasks keep Done / Not applicable.\n * Blocking tasks are flagged. Callback data is `done:<id>` / `skip:<id>`.\n * @returns {{ text: string, inline_keyboard: object[][] }}\n */\nfunction renderMessage(tasks) {\n const byEmployee = new Map();\n for (const t of tasks) {\n if (!byEmployee.has(t.employee_id)) byEmployee.set(t.employee_id, []);\n byEmployee.get(t.employee_id).push(t);\n }\n\n const lines = ['Onboarding tasks:'];\n const inline_keyboard = [];\n\n for (const [, empTasks] of byEmployee) {\n const { employee_name, start_date } = empTasks[0];\n lines.push('', `${employee_name} \u2014 starts ${start_date}`);\n for (const t of empTasks) {\n if (t.status === 'Done') {\n lines.push(` [done] ${t.title}`);\n } else if (t.status === 'Skipped') {\n lines.push(` [n/a] ${t.title}`);\n } else {\n const flag = t.blocking ? '\u26a0\ufe0f ' : '';\n lines.push(` ${flag}${t.title} (due ${t.due_date})`);\n inline_keyboard.push([\n { text: '\u2705 Done', callback_data: `done:${t.id}` },\n { text: '\ud83d\udeab Not applicable', callback_data: `skip:${t.id}` },\n ]);\n }\n }\n }\n\n return { text: lines.join('\\n'), inline_keyboard };\n}\n\nconst CALLBACK_RE = /^(done|skip):(rec[A-Za-z0-9]+)$/;\n\n/**\n * Parse Telegram callback_data. Strict: anything that is not exactly `done:<recId>` or\n * `skip:<recId>` returns null (caller logs and ignores) \u2014 callbacks can arrive from a\n * stale message or another bot, so this must never throw.\n * @returns {{ action: 'done'|'skip', taskId: string } | null}\n */\nfunction parseCallback(data) {\n if (typeof data !== 'string') return null;\n const m = CALLBACK_RE.exec(data);\n return m ? { action: m[1], taskId: m[2] } : null;\n}\n\n/**\n * Decide what a callback should do, given the current task, who tapped, and the action.\n * - not the assignee -> 'unauthorized'\n * - task already Done/Skipped -> 'already-final' (idempotent: do not rewrite Completed_At)\n * - otherwise -> 'apply' with the target status\n * @returns {{ outcome: 'unauthorized'|'already-final'|'apply', status?: 'Done'|'Skipped' }}\n */\nfunction resolveCallback({ task, fromTelegramId, action }) {\n if (String(task.assignee_telegram_id) !== String(fromTelegramId)) {\n return { outcome: 'unauthorized' };\n }\n if (task.status !== 'Pending') {\n return { outcome: 'already-final' };\n }\n return { outcome: 'apply', status: action === 'done' ? 'Done' : 'Skipped' };\n}\n\n/** True when none of an employee's tasks are still Pending (all Done or Skipped). */\nfunction isEmployeeComplete(employeeTasks) {\n return employeeTasks.length > 0 && employeeTasks.every((t) => t.status !== 'Pending');\n}\n\n// --- n8n glue ---------------------------------------------------------------\nconst flat = (v) => (Array.isArray(v) ? v[0] : v);\nconst tz = $env.OFFICE_TIMEZONE || 'UTC';\nconst today = officeToday(new Date(), tz);\n\nconst empById = {};\nfor (const i of $('Fetch Employees').all()) {\n empById[i.json.id] = { name: i.json.Full_Name, start_date: i.json.Start_Date };\n}\n\nconst tasks = items.map((i) => {\n const f = i.json;\n const employee_id = flat(f.Employee);\n const emp = empById[employee_id] || {};\n return {\n id: f.id,\n title: f.Title,\n due_date: f.Due_Date,\n blocking: f.Blocking === true,\n assignee_telegram_id: f.Assignee_Telegram_ID,\n status: f.Status,\n notified_at: f.Notified_At || '',\n employee_id,\n employee_name: emp.name,\n start_date: emp.start_date,\n };\n});\n\nreturn groupByAssignee(selectDueTasks(tasks, today))\n .filter((g) => g.assignee_telegram_id && g.assignee_telegram_id !== 'undefined')\n .map((g) => {\n const { text, inline_keyboard } = renderMessage(g.tasks);\n return { json: { assignee_telegram_id: g.assignee_telegram_id, text, reply_markup: { inline_keyboard }, task_ids: g.tasks.map((t) => t.id) } };\n });\n"
},
"id": "Build Messages",
"name": "Build Messages",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
260,
300
]
},
{
"parameters": {
"method": "POST",
"url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/sendMessage",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ chat_id: $json.assignee_telegram_id, text: $json.text, reply_markup: $json.reply_markup }) }}",
"options": {}
},
"id": "Send Telegram",
"name": "Send Telegram",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
480,
300
],
"onError": "continueRegularOutput",
"notes": "Continue on fail: one bad send must not halt the batch or block write-back for the others."
},
{
"parameters": {
"language": "javaScript",
"jsCode": "const built = $('Build Messages').all();\nconst okByChat = {};\nfor (const s of $('Send Telegram').all()) { const r = s.json && s.json.result; if (r && r.chat) okByChat[String(r.chat.id)] = r; }\nconst now = new Date().toISOString();\nconst out = [];\nfor (const g of built) {\n const r = okByChat[String(g.json.assignee_telegram_id)];\n if (!r) continue; // no successful send for this group -> leave un-notified, retried next run\n for (const taskId of g.json.task_ids || []) {\n out.push({ json: { id: taskId, Notified_At: now, Telegram_Message_ID: String(r.message_id), Telegram_Chat_ID: String(r.chat.id) } });\n }\n}\nreturn out;"
},
"id": "Notified Updates",
"name": "Notified Updates",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
700,
200
]
},
{
"parameters": {
"method": "PATCH",
"url": "=https://api.airtable.com/v0/{{ $env.AIRTABLE_BASE_ID }}/Onboarding_Tasks/{{ $json.id }}",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "airtableTokenApi",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ fields: { Notified_At: $json.Notified_At, Telegram_Message_ID: $json.Telegram_Message_ID, Telegram_Chat_ID: $json.Telegram_Chat_ID } }) }}",
"options": {}
},
"id": "Mark Notified",
"name": "Mark Notified",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
920,
200
],
"notes": "Airtable update via HTTP PATCH; attach the Airtable PAT credential (Predefined Credential Type)."
},
{
"parameters": {
"language": "javaScript",
"jsCode": "const okChats = new Set();\nfor (const s of $('Send Telegram').all()) { const r = s.json && s.json.result; if (r && r.chat) okChats.add(String(r.chat.id)); }\nconst failed = $('Build Messages').all().filter((g) => !okChats.has(String(g.json.assignee_telegram_id)));\nif (failed.length === 0) return [{ json: { hasFailures: false, message: '' } }];\nconst lines = failed.map((g) => '- assignee ' + g.json.assignee_telegram_id + ': ' + g.json.task_ids.length + ' task(s) not delivered');\nconst message = 'F3: ' + failed.length + ' onboarding notification(s) could not be delivered (assignee unreachable in Telegram). These tasks were NOT marked notified and will retry:\\n' + lines.join('\\n');\nreturn [{ json: { hasFailures: true, message } }];"
},
"id": "Failed Sends",
"name": "Failed Sends",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
700,
420
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"leftValue": "={{ $json.hasFailures }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
]
}
},
"id": "IF Has Failures",
"name": "IF Has Failures",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
920,
420
]
},
{
"parameters": {
"method": "POST",
"url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/sendMessage",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ chat_id: $env.TELEGRAM_HR_CHAT_ID, text: $json.message }) }}",
"options": {}
},
"id": "Notify HR (failed sends)",
"name": "Notify HR (failed sends)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1140,
420
]
}
],
"connections": {
"Schedule 09:00": {
"main": [
[
{
"node": "Fetch Employees",
"type": "main",
"index": 0
}
]
]
},
"Fetch Employees": {
"main": [
[
{
"node": "Fetch Pending Tasks",
"type": "main",
"index": 0
}
]
]
},
"Fetch Pending Tasks": {
"main": [
[
{
"node": "Build Messages",
"type": "main",
"index": 0
}
]
]
},
"Build Messages": {
"main": [
[
{
"node": "Send Telegram",
"type": "main",
"index": 0
}
]
]
},
"Send Telegram": {
"main": [
[
{
"node": "Notified Updates",
"type": "main",
"index": 0
},
{
"node": "Failed Sends",
"type": "main",
"index": 0
}
]
]
},
"Notified Updates": {
"main": [
[
{
"node": "Mark Notified",
"type": "main",
"index": 0
}
]
]
},
"Failed Sends": {
"main": [
[
{
"node": "IF Has Failures",
"type": "main",
"index": 0
}
]
]
},
"IF Has Failures": {
"main": [
[
{
"node": "Notify HR (failed sends)",
"type": "main",
"index": 0
}
],
[]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1",
"timezone": "Europe/Istanbul"
},
"meta": {
"note": "Day boundary handled in code (officeToday). Sends matched back by chat id, not position. Undelivered notifications are reported to HR and retried (not marked notified). Set Settings -> Error Workflow too. TELEGRAM_BOT_TOKEN / TELEGRAM_HR_CHAT_ID + Airtable credential required."
}
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
02 - Notify (F3). Uses airtable, httpRequest. Scheduled trigger; 10 nodes.
Source: https://github.com/ibragim-0202/employee-onboarding-automation/blob/main/workflows/02-notify.json — 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.
I prepared a detailed guide that showed the whole process of integrating the Binance API and storing data in Airtable to manage funding statements associated with tokens in a wallet.
Stop wasting hours on manual dialing and listening to ringtones. This workflow transforms your Airtable into a high-velocity AI Call Center using Vapi AI**.
Reel-Analysis-Of-Favourite-Content-Creator. Uses httpRequest, airtable. Scheduled trigger; 26 nodes.
Link-By-Reel-Analysis. Uses httpRequest, airtable. Scheduled trigger; 24 nodes.
> Transform your content strategy with automated competitor intelligence