n8n recipes
The lead webhook already carries everything an automation needs — the contact, the cart total, the currency, a signature to prove it came from StorePilot. What this page adds is the other half: three working n8n workflows you can import as-is and adapt.
Each recipe below is a complete workflow export. In n8n choose Workflows → Import from File / Clipboard, paste the JSON, then:
- Open the Webhook node, copy its production URL, and paste it into
Settings → Integrations → n8n Leads Webhook (
n8n_leads_webhook) for your site. - Set a signing secret in the same settings panel, and put the same value into the
workflow's verification node (
SECRETbelow). - Wire the last node to your own account (SMTP credentials, Slack connection, CRM API key).
All three recipes trigger on the same new_lead event; what differs is what they do with it.
Verifying the signature (all recipes)
Every recipe starts with the same two nodes: a Webhook trigger with the Raw Body option enabled, and a Code node that recomputes the HMAC and refuses anything that does not match.
Two details are load-bearing, and both are the receiving side of rules StorePilot itself follows:
- The HMAC is computed over the raw bytes, which is why the Webhook node must have
rawBodyenabled. Parsing the JSON and re-serialising it before comparing is how a signature silently stops matching — key order and whitespace are not guaranteed to survive the round trip. - The comparison is
crypto.timingSafeEqual, not===. A character-by-character string comparison leaks how much of the signature matched through timing.
// Code node: verify X-StorePilot-Signature over the raw body.
const crypto = require('crypto');
const SECRET = 'your-signing-secret'; // the same value you saved in StorePilot
const item = $input.first();
const signature = item.json.headers['x-storepilot-signature'] ?? '';
const rawBody = Buffer.from(item.binary.data.data, 'base64'); // rawBody: true stores it here
const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw new Error('Invalid webhook signature — dropping the event.');
}
return [{ json: JSON.parse(rawBody.toString('utf8')) }];
A site with no signing secret configured sends no signature header. The recipes assume you set one — an automation that emails your customers should not act on unauthenticated input.
Recipe 1 — abandoned-cart email, with a delay
Waits 45 minutes, re-checks nothing (the webhook fires once per new lead), and emails the customer a nudge — but only when the lead is a checkout abandonment with an email address. The delay is not decoration: a mail sent instantly races the customer to their own checkout, and "you left something behind" arriving mid-payment reads as broken tracking.
The IF node gates on three fields of the payload: lead.category is checkout,
lead.email is present, and lead.status is not ordered (a lead that converted before the
webhook was processed needs no nudge).
{
"name": "StorePilot — abandoned cart email",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "storepilot-abandoned-cart",
"options": { "rawBody": true }
},
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [0, 0]
},
{
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "const crypto = require('crypto');\nconst SECRET = 'your-signing-secret';\nconst item = $input.first();\nconst signature = item.json.headers['x-storepilot-signature'] ?? '';\nconst rawBody = Buffer.from(item.binary.data.data, 'base64');\nconst expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');\nconst a = Buffer.from(expected);\nconst b = Buffer.from(signature);\nif (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {\n throw new Error('Invalid webhook signature');\n}\nreturn [{ json: JSON.parse(rawBody.toString('utf8')) }];"
},
"name": "Verify signature",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [200, 0]
},
{
"parameters": {
"conditions": {
"string": [
{ "value1": "={{ $json.lead.category }}", "operation": "equals", "value2": "checkout" },
{ "value1": "={{ $json.lead.email }}", "operation": "isNotEmpty" }
],
"boolean": [],
"number": []
},
"combineOperation": "all"
},
"name": "Checkout lead with an email?",
"type": "n8n-nodes-base.if",
"typeVersion": 1,
"position": [400, 0]
},
{
"parameters": { "unit": "minutes", "amount": 45 },
"name": "Wait 45 minutes",
"type": "n8n-nodes-base.wait",
"typeVersion": 1,
"position": [600, -80]
},
{
"parameters": {
"fromEmail": "shop@example.com",
"toEmail": "={{ $json.lead.email }}",
"subject": "You left something in your cart",
"text": "=Hi {{ $json.lead.firstName || 'there' }},\n\nYou left {{ $json.lead.cartItemCount }} item(s) worth {{ $json.lead.cartTotal }} {{ $json.lead.currency }} in your cart.\n\nFinish your order any time — your cart is saved.\n"
},
"name": "Send nudge email",
"type": "n8n-nodes-base.emailSend",
"typeVersion": 2,
"position": [800, -80]
}
],
"connections": {
"Webhook": { "main": [[{ "node": "Verify signature", "type": "main", "index": 0 }]] },
"Verify signature": { "main": [[{ "node": "Checkout lead with an email?", "type": "main", "index": 0 }]] },
"Checkout lead with an email?": { "main": [[{ "node": "Wait 45 minutes", "type": "main", "index": 0 }], []] },
"Wait 45 minutes": { "main": [[{ "node": "Send nudge email", "type": "main", "index": 0 }]] }
}
}
The default (summary) webhook payload is enough for this flow. If your template needs the
line items themselves, switch the site's webhook_payload setting to full and read
lead.cartData — but a total and a count sell the nudge just as well without shipping the
whole cart to a third party.
Recipe 2 — a card in your CRM
Creates a contact in whatever CRM you use, via a generic HTTP Request node — swap the URL
and auth for HubSpot, Pipedrive, Attio, or your own API. The mapping is deliberately flat:
lead.email, lead.firstName, lead.lastName, lead.phone, lead.company, plus
lead.formName and lead.source so the CRM records where the contact came from.
{
"name": "StorePilot — lead to CRM",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "storepilot-crm-card",
"options": { "rawBody": true }
},
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [0, 0]
},
{
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "const crypto = require('crypto');\nconst SECRET = 'your-signing-secret';\nconst item = $input.first();\nconst signature = item.json.headers['x-storepilot-signature'] ?? '';\nconst rawBody = Buffer.from(item.binary.data.data, 'base64');\nconst expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');\nconst a = Buffer.from(expected);\nconst b = Buffer.from(signature);\nif (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {\n throw new Error('Invalid webhook signature');\n}\nreturn [{ json: JSON.parse(rawBody.toString('utf8')) }];"
},
"name": "Verify signature",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [200, 0]
},
{
"parameters": {
"conditions": {
"string": [
{ "value1": "={{ $json.lead.email }}", "operation": "isNotEmpty" }
],
"boolean": [],
"number": []
}
},
"name": "Has an email?",
"type": "n8n-nodes-base.if",
"typeVersion": 1,
"position": [400, 0]
},
{
"parameters": {
"method": "POST",
"url": "https://your-crm.example.com/api/contacts",
"sendBody": true,
"bodyParameters": {
"parameters": [
{ "name": "email", "value": "={{ $json.lead.email }}" },
{ "name": "first_name", "value": "={{ $json.lead.firstName }}" },
{ "name": "last_name", "value": "={{ $json.lead.lastName }}" },
{ "name": "phone", "value": "={{ $json.lead.phone }}" },
{ "name": "company", "value": "={{ $json.lead.company }}" },
{ "name": "source", "value": "=StorePilot / {{ $json.lead.formName || $json.lead.source }}" }
]
}
},
"name": "Create CRM contact",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [600, -80]
}
],
"connections": {
"Webhook": { "main": [[{ "node": "Verify signature", "type": "main", "index": 0 }]] },
"Verify signature": { "main": [[{ "node": "Has an email?", "type": "main", "index": 0 }]] },
"Has an email?": { "main": [[{ "node": "Create CRM contact", "type": "main", "index": 0 }], []] }
}
}
Deduplication is the CRM's job, not the workflow's — every serious CRM API upserts by email. The webhook fires once per new lead, so the workflow itself introduces no duplicates.
Recipe 3 — Slack message for high-value abandonments
Posts to a Slack channel only for the segment worth interrupting someone over: a checkout abandonment whose cart total clears a threshold. Everything else stays out of the channel — an alert stream that carries every lead is one nobody reads.
{
"name": "StorePilot — high-value cart to Slack",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "storepilot-slack-segment",
"options": { "rawBody": true }
},
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [0, 0]
},
{
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "const crypto = require('crypto');\nconst SECRET = 'your-signing-secret';\nconst item = $input.first();\nconst signature = item.json.headers['x-storepilot-signature'] ?? '';\nconst rawBody = Buffer.from(item.binary.data.data, 'base64');\nconst expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');\nconst a = Buffer.from(expected);\nconst b = Buffer.from(signature);\nif (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {\n throw new Error('Invalid webhook signature');\n}\nreturn [{ json: JSON.parse(rawBody.toString('utf8')) }];"
},
"name": "Verify signature",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [200, 0]
},
{
"parameters": {
"conditions": {
"string": [
{ "value1": "={{ $json.lead.category }}", "operation": "equals", "value2": "checkout" }
],
"number": [
{ "value1": "={{ Number($json.lead.cartTotal) }}", "operation": "larger", "value2": 100 }
],
"boolean": []
},
"combineOperation": "all"
},
"name": "Checkout over 100?",
"type": "n8n-nodes-base.if",
"typeVersion": 1,
"position": [400, 0]
},
{
"parameters": {
"channel": "#sales",
"text": "=🛒 Abandoned cart: {{ $json.lead.cartTotal }} {{ $json.lead.currency }} ({{ $json.lead.cartItemCount }} items) — {{ $json.lead.firstName }} {{ $json.lead.lastName }} <{{ $json.lead.email }}>. Session {{ $json.lead.sessionId }}."
},
"name": "Post to Slack",
"type": "n8n-nodes-base.slack",
"typeVersion": 2,
"position": [600, -80]
}
],
"connections": {
"Webhook": { "main": [[{ "node": "Verify signature", "type": "main", "index": 0 }]] },
"Verify signature": { "main": [[{ "node": "Checkout over 100?", "type": "main", "index": 0 }]] },
"Checkout over 100?": { "main": [[{ "node": "Post to Slack", "type": "main", "index": 0 }]] }
}
}
The lead.sessionId in the message is the join key back into StorePilot — paste it into the
recordings search to watch the visit that abandoned. (There is deliberately no visitor id in
the payload; the session is how a webhook receiver refers back to a lead.)
Field reference
Every field the recipes read is part of the default (summary) payload documented in
Webhooks: lead.email, lead.firstName, lead.lastName, lead.phone,
lead.company, lead.category, lead.status, lead.leadType, lead.source,
lead.formName, lead.cartItemCount, lead.cartTotal, lead.currency, lead.sessionId,
lead.createdAt. Postal address, order notes and line items exist only in the opt-in full
mode — and none of these recipes need them.