Skip to main content

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:

  1. Open the Webhook node, copy its production URL, and paste it into Settings → Integrations → n8n Leads Webhook (n8n_leads_webhook) for your site.
  2. Set a signing secret in the same settings panel, and put the same value into the workflow's verification node (SECRET below).
  3. 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 rawBody enabled. 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.