This workflow corresponds to n8n.io template #16154 — we link there as the canonical source.
This workflow follows the Emailsend → Form Trigger 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 →
{
"id": "5XbBCTfQqteczaNfkok_t",
"name": "Plan delivery routes from Notion orders and email the schedule",
"tags": [],
"nodes": [
{
"id": "fab3ff69-4e04-4b97-8d2c-6dc467082007",
"name": "Overview and setup",
"type": "n8n-nodes-base.stickyNote",
"position": [
0,
-32
],
"parameters": {
"width": 1360,
"height": 884,
"content": "## Plan delivery routes from Notion orders and email the schedule\n\nBatch delivery orders into a Notion database, then on a schedule email an optimized route plan grouped by delivery day, with nearest-neighbor stop ordering and per-stop payment tags.\n\n## Who's it for\n\nSmall delivery operations such as lunch box services, home bakeries, and meal prep rounds that collect orders over time and plan the driving route by hand.\n\n## How it works\n\nA form saves each order to Notion. On a schedule (Friday 6pm by default) the workflow reads pending orders, geocodes addresses through OpenStreetMap Nominatim (free, no API key), sorts each delivery day's stops by nearest-neighbor distance, and emails a plan with [PAID] or [COLLECT ON DELIVERY] tagged on every stop.\n\n## How to set up\n\n1. Create a Notion database with these properties (names and select options are case-sensitive): Customer Name (Title), Order ID (Text), Phone (Phone), Address (Text), Items (Text), Total (Number), Day (Select: Saturday, Sunday), Paid (Checkbox), Notes (Text), Status (Select: pending, done), Received At (Date).\n2. Create a Notion integration (Internal) at notion.so/profile/integrations, copy the secret, and connect it to the database via the database ... menu, Connections.\n3. In n8n add a Notion API credential and an SMTP credential.\n4. Paste your Notion database ID into both Notion nodes (Notion: Create Order and Notion: Get Pending).\n5. Open Email Plan, assign the SMTP credential, and set From and To to real addresses.\n6. Open Order Intake Form, copy the Production URL (bookmark it as your order form), then activate the workflow.\n\n## Requirements\n\n- An n8n instance (cloud or self-hosted)\n- A Notion workspace and an Internal integration\n- Any SMTP account (Gmail app password, Sendgrid, or your own server)\n\n## How to customize\n\n- Change the schedule or the Delivery Day options for non-weekend rounds.\n- Swap Email Plan for a Telegram or Slack node to receive the plan on your phone.\n- Switch to a paid geocoder (Google, Mapbox) for large order volumes."
},
"typeVersion": 1
},
{
"id": "f9662cd6-3a4d-4684-aae9-f7766ebcbd15",
"name": "Intake section",
"type": "n8n-nodes-base.stickyNote",
"position": [
16,
896
],
"parameters": {
"color": 5,
"width": 312,
"height": 180,
"content": "### Intake\n\nForm \u2192 Save Order \u2192 Notion. Each submission creates one page in the Notion database.\n\nPayment Status set here is reflected on the planned route."
},
"typeVersion": 1
},
{
"id": "b812b3e3-9908-4def-952f-43e22a667a48",
"name": "Planning section",
"type": "n8n-nodes-base.stickyNote",
"position": [
16,
1152
],
"parameters": {
"color": 4,
"width": 296,
"height": 204,
"content": "### Planning\n\nNotion (filter status=pending) \u2192 Load Pending Orders \u2192 geocode \u2192 optimize \u2192 email.\n\nGeocode rate-limited to 1 req/1.1s for Nominatim. First entry per day anchors the nearest-neighbor sort."
},
"typeVersion": 1
},
{
"id": "761e2b2d-ab5d-440f-9332-1603dcf4d3cf",
"name": "Order Intake Form",
"type": "n8n-nodes-base.formTrigger",
"position": [
368,
928
],
"parameters": {
"path": "bd511d74-2d79-47b9-8cf8-93802737c7f0",
"options": {},
"formTitle": "New Delivery Order",
"formFields": {
"values": [
{
"fieldLabel": "Customer Name",
"requiredField": true
},
{
"fieldLabel": "Phone",
"requiredField": true
},
{
"fieldType": "textarea",
"fieldLabel": "Delivery Address",
"placeholder": "Street, city, state, ZIP",
"requiredField": true
},
{
"fieldType": "textarea",
"fieldLabel": "Order Details",
"placeholder": "What they ordered, e.g. 2 biryani, 1 naan",
"requiredField": true
},
{
"fieldType": "number",
"fieldLabel": "Total",
"placeholder": "Dollar amount",
"requiredField": true
},
{
"fieldType": "dropdown",
"fieldLabel": "Delivery Day",
"fieldOptions": {
"values": [
{
"option": "Saturday"
},
{
"option": "Sunday"
}
]
},
"requiredField": true
},
{
"fieldType": "dropdown",
"fieldLabel": "Payment Status",
"fieldOptions": {
"values": [
{
"option": "Paid"
},
{
"option": "Collect on delivery"
}
]
},
"requiredField": true
},
{
"fieldType": "textarea",
"fieldLabel": "Notes"
}
]
},
"responseMode": "lastNode",
"formDescription": "Add a new order to the delivery batch."
},
"typeVersion": 2.1
},
{
"id": "84364596-334c-4a8e-870f-64265a550b31",
"name": "Save Order",
"type": "n8n-nodes-base.code",
"position": [
640,
928
],
"parameters": {
"jsCode": "const form = $input.first().json;\nconst orderId = 'ORD-' + Date.now().toString(36).toUpperCase().slice(-6);\n\nreturn [{\n json: {\n orderId,\n customer: form['Customer Name'],\n phone: form['Phone'],\n address: form['Delivery Address'],\n items: form['Order Details'],\n total: Number(form['Total']) || 0,\n day: form['Delivery Day'],\n paid: form['Payment Status'] === 'Paid',\n notes: form['Notes'] || '',\n status: 'pending',\n receivedAt: new Date().toISOString(),\n message: `Order ${orderId} received. ${form['Customer Name']} for ${form['Delivery Day']}.`\n }\n}];\n"
},
"typeVersion": 2,
"alwaysOutputData": true
},
{
"id": "91dbf8ee-b2a0-4e65-8bf0-64ed2d1d1751",
"name": "Notion: Create Order",
"type": "n8n-nodes-base.notion",
"position": [
912,
928
],
"parameters": {
"title": "={{ $json.customer }}",
"options": {},
"resource": "databasePage",
"databaseId": {
"__rl": true,
"mode": "id",
"value": "YOUR_NOTION_DATABASE_ID_HERE"
},
"propertiesUi": {
"propertyValues": [
{
"key": "Order ID|rich_text",
"textContent": "={{ $json.orderId }}"
},
{
"key": "Phone|phone_number",
"phoneValue": "={{ $json.phone }}"
},
{
"key": "Address|rich_text",
"textContent": "={{ $json.address }}"
},
{
"key": "Items|rich_text",
"textContent": "={{ $json.items }}"
},
{
"key": "Total|number",
"numberValue": "={{ $json.total }}"
},
{
"key": "Day|select",
"selectValue": "={{ $json.day }}"
},
{
"key": "Paid|checkbox",
"checkboxValue": "={{ $json.paid }}"
},
{
"key": "Notes|rich_text",
"textContent": "={{ $json.notes }}"
},
{
"key": "Status|select",
"selectValue": "pending"
},
{
"key": "Received At|date",
"date": "={{ $json.receivedAt }}"
}
]
}
},
"typeVersion": 2
},
{
"id": "90dd78d7-07dd-403f-8ed3-24b9d8ba55ac",
"name": "Friday 6pm Plan",
"type": "n8n-nodes-base.scheduleTrigger",
"position": [
368,
1136
],
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 18 * * 5"
}
]
}
},
"typeVersion": 1.2
},
{
"id": "8ebdb8ae-f834-46d4-b234-95ac51e53b40",
"name": "Test Run",
"type": "n8n-nodes-base.manualTrigger",
"position": [
368,
1280
],
"parameters": {},
"typeVersion": 1
},
{
"id": "a12d139f-787d-4259-a8ca-23587869b799",
"name": "Notion: Get Pending",
"type": "n8n-nodes-base.notion",
"position": [
592,
1216
],
"parameters": {
"simple": false,
"filters": {
"conditions": [
{
"key": "Status|select",
"condition": "equals",
"selectValue": "pending"
}
]
},
"options": {},
"resource": "databasePage",
"operation": "getAll",
"returnAll": true,
"databaseId": {
"__rl": true,
"mode": "id",
"value": "YOUR_NOTION_DATABASE_ID_HERE"
},
"filterType": "manual"
},
"typeVersion": 2,
"alwaysOutputData": true
},
{
"id": "4c1b0db2-d6df-4976-8090-2805e70ec693",
"name": "Load Pending Orders",
"type": "n8n-nodes-base.code",
"position": [
784,
1216
],
"parameters": {
"jsCode": "const items = $input.all();\nconst realPages = items.filter(item => item.json && item.json.id && item.json.properties);\n\nif (realPages.length === 0) {\n return [{ json: { _empty: true } }];\n}\n\nconst text = (prop) => {\n if (!prop) return '';\n if (Array.isArray(prop.title)) return prop.title.map(t => t.plain_text).join('');\n if (Array.isArray(prop.rich_text)) return prop.rich_text.map(t => t.plain_text).join('');\n return '';\n};\n\nconst allOrders = realPages.map(item => {\n const props = item.json.properties || {};\n return {\n id: text(props['Order ID']),\n customer: text(props['Customer Name']),\n phone: (props['Phone'] && props['Phone'].phone_number) || '',\n address: text(props['Address']),\n items: text(props['Items']),\n total: (props['Total'] && props['Total'].number) || 0,\n day: (props['Day'] && props['Day'].select && props['Day'].select.name) || '',\n paid: (props['Paid'] && props['Paid'].checkbox) || false,\n notes: text(props['Notes']),\n status: (props['Status'] && props['Status'].select && props['Status'].select.name) || 'pending',\n receivedAt: (props['Received At'] && props['Received At'].date && props['Received At'].date.start) || ''\n };\n});\n\nconst pending = allOrders.filter(o => o.status === 'pending');\n\nif (pending.length === 0) {\n return [{ json: { _empty: true } }];\n}\n\nreturn pending.map(order => ({ json: order }));\n"
},
"typeVersion": 2
},
{
"id": "e5228cf1-c320-43a8-9bdf-d65450ea9495",
"name": "Geocode (Nominatim)",
"type": "n8n-nodes-base.httpRequest",
"position": [
992,
1216
],
"parameters": {
"url": "https://nominatim.openstreetmap.org/search",
"options": {
"batching": {
"batch": {
"batchSize": 1,
"batchInterval": 1100
}
},
"response": {
"response": {
"neverError": true
}
}
},
"sendQuery": true,
"sendHeaders": true,
"queryParameters": {
"parameters": [
{
"name": "q",
"value": "={{ $json.address || '' }}"
},
{
"name": "format",
"value": "json"
},
{
"name": "limit",
"value": "1"
}
]
},
"headerParameters": {
"parameters": [
{
"name": "User-Agent",
"value": "local-delivery-route-planner (n8n template)"
}
]
}
},
"typeVersion": 4.2,
"alwaysOutputData": true
},
{
"id": "20fb5cc3-8e37-4778-9c2a-655ce31ade76",
"name": "Optimize Routes & Format",
"type": "n8n-nodes-base.code",
"position": [
1184,
1216
],
"parameters": {
"jsCode": "const geoResults = $input.all();\nconst originals = $('Load Pending Orders').all();\n\nif (originals.length === 1 && originals[0].json._empty) {\n return [{\n json: {\n empty: true,\n markdown: '# Delivery Plan\\n\\nNo pending orders right now.'\n }\n }];\n}\n\nconst enriched = originals.map((item, i) => {\n const order = item.json;\n const geoRaw = geoResults[i] ? geoResults[i].json : null;\n let lat = null;\n let lon = null;\n const result = Array.isArray(geoRaw) ? geoRaw[0] : geoRaw;\n if (result && result.lat) {\n lat = parseFloat(result.lat);\n lon = parseFloat(result.lon);\n }\n return { ...order, lat, lon, geocodeFailed: lat === null };\n});\n\nfunction haversine(a, b) {\n if (a.lat == null || b.lat == null) return Infinity;\n const R = 6371;\n const toRad = (d) => d * Math.PI / 180;\n const dLat = toRad(b.lat - a.lat);\n const dLon = toRad(b.lon - a.lon);\n const lat1 = toRad(a.lat);\n const lat2 = toRad(b.lat);\n const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;\n return R * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));\n}\n\nfunction optimize(stops) {\n if (stops.length <= 1) return stops;\n const valid = stops.filter(s => s.lat != null);\n const invalid = stops.filter(s => s.lat == null);\n if (valid.length === 0) return stops;\n const route = [valid[0]];\n const remaining = valid.slice(1);\n while (remaining.length > 0) {\n const current = route[route.length - 1];\n let bestIdx = 0;\n let bestDist = Infinity;\n for (let i = 0; i < remaining.length; i++) {\n const d = haversine(current, remaining[i]);\n if (d < bestDist) {\n bestDist = d;\n bestIdx = i;\n }\n }\n route.push(remaining.splice(bestIdx, 1)[0]);\n }\n return [...route, ...invalid];\n}\n\nconst saturday = optimize(enriched.filter(o => o.day === 'Saturday'));\nconst sunday = optimize(enriched.filter(o => o.day === 'Sunday'));\n\nfunction formatDay(label, stops) {\n if (stops.length === 0) return `## ${label}\\n\\nNo deliveries planned.\\n\\n`;\n let total = 0;\n let unpaid = 0;\n let md = `## ${label} (${stops.length} ${stops.length === 1 ? 'stop' : 'stops'})\\n\\n`;\n stops.forEach((s, i) => {\n total += s.total;\n if (!s.paid) unpaid += s.total;\n md += `### Stop ${i + 1}: ${s.customer}\\n`;\n md += `- Order ID: ${s.id}\\n`;\n md += `- Phone: ${s.phone}\\n`;\n md += `- Address: ${s.address}\\n`;\n md += `- Items: ${s.items}\\n`;\n md += `- Total: $${s.total.toFixed(2)} ${s.paid ? '[PAID]' : '[COLLECT ON DELIVERY]'}\\n`;\n if (s.notes) md += `- Notes: ${s.notes}\\n`;\n if (s.geocodeFailed) md += `- [WARN] Address could not be geocoded, placed at end of route\\n`;\n md += '\\n';\n });\n md += `**${label} totals:** $${total.toFixed(2)} revenue`;\n if (unpaid > 0) md += `, $${unpaid.toFixed(2)} to collect on route`;\n md += '\\n\\n';\n return md;\n}\n\nconst planDate = new Date().toISOString().slice(0, 10);\nconst markdown =\n `# Delivery Plan (${planDate})\\n\\n` +\n `${enriched.length} total orders, ${saturday.length} Saturday, ${sunday.length} Sunday.\\n\\n` +\n formatDay('Saturday', saturday) +\n formatDay('Sunday', sunday) +\n `---\\n\\nRoutes optimized by nearest-neighbor on geocoded coordinates from Nominatim/OpenStreetMap.\\n\\nMark orders fulfilled in workflow static data once delivered.\\n`;\n\nreturn [{\n json: {\n planDate,\n markdown,\n saturday: {\n stops: saturday.length,\n revenue: saturday.reduce((s, o) => s + o.total, 0),\n toCollect: saturday.filter(o => !o.paid).reduce((s, o) => s + o.total, 0),\n orders: saturday\n },\n sunday: {\n stops: sunday.length,\n revenue: sunday.reduce((s, o) => s + o.total, 0),\n toCollect: sunday.filter(o => !o.paid).reduce((s, o) => s + o.total, 0),\n orders: sunday\n }\n }\n}];\n"
},
"typeVersion": 2
},
{
"id": "00f566ac-790a-4fad-a220-08e450d7dbc9",
"name": "Email Plan",
"type": "n8n-nodes-base.emailSend",
"position": [
1392,
1216
],
"parameters": {
"text": "={{ $json.markdown }}",
"options": {},
"subject": "={{ \"Delivery Plan \" + $json.planDate }}",
"toEmail": "friend@example.com",
"fromEmail": "you@example.com",
"emailFormat": "text"
},
"typeVersion": 2.1
},
{
"id": "784b7eba-397e-432c-8acd-52abfe47f8a0",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"position": [
0,
880
],
"parameters": {
"color": 5,
"width": 1104,
"height": 208,
"content": ""
},
"typeVersion": 1
},
{
"id": "045f5007-b8ca-4387-9e01-408da53f1153",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"position": [
0,
1120
],
"parameters": {
"color": 4,
"width": 1584,
"height": 304,
"content": ""
},
"typeVersion": 1
}
],
"active": false,
"settings": {
"availableInMCP": false,
"executionOrder": "v1"
},
"versionId": "4665b81d-78ce-46c5-bf73-d901db973b47",
"connections": {
"Test Run": {
"main": [
[
{
"node": "Notion: Get Pending",
"type": "main",
"index": 0
}
]
]
},
"Save Order": {
"main": [
[
{
"node": "Notion: Create Order",
"type": "main",
"index": 0
}
]
]
},
"Friday 6pm Plan": {
"main": [
[
{
"node": "Notion: Get Pending",
"type": "main",
"index": 0
}
]
]
},
"Order Intake Form": {
"main": [
[
{
"node": "Save Order",
"type": "main",
"index": 0
}
]
]
},
"Geocode (Nominatim)": {
"main": [
[
{
"node": "Optimize Routes & Format",
"type": "main",
"index": 0
}
]
]
},
"Load Pending Orders": {
"main": [
[
{
"node": "Geocode (Nominatim)",
"type": "main",
"index": 0
}
]
]
},
"Notion: Get Pending": {
"main": [
[
{
"node": "Load Pending Orders",
"type": "main",
"index": 0
}
]
]
},
"Optimize Routes & Format": {
"main": [
[
{
"node": "Email Plan",
"type": "main",
"index": 0
}
]
]
}
}
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This workflow collects delivery orders via an n8n form into a Notion database, then on a weekly schedule pulls pending orders, geocodes addresses with OpenStreetMap Nominatim, generates a nearest-neighbor route plan grouped by delivery day, and emails the plan as a text…
Source: https://n8n.io/workflows/16154/ — 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.
Retrieves workflows directly from an n8n instance using the n8n API Dynamically generates a form to select which workflows to import Supports both fixed instance configuration and dynamic source/targe
This workflow is built for real estate investors, private investigators, recruiters, and sales teams who need to skip trace individuals -- finding contact details, addresses, and phone numbers from a
Stopanderror Splitout. Uses outputParserStructured, lmChatOpenAi, formTrigger, chainLlm. Event-driven trigger; 85 nodes.
Deep Research old(fr). Uses outputParserStructured, formTrigger, chainLlm, form. Event-driven trigger; 79 nodes.
Deep Research old(fr). Uses outputParserStructured, formTrigger, chainLlm, form. Event-driven trigger; 79 nodes.