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": "Stripe Lifecycle to Slack (Signed Webhooks, Per-Event Messages)",
"nodes": [
{
"parameters": {
"content": "## Stripe Lifecycle to Slack\n\nStripe webhook lands here, the signature is verified against your `STRIPE_WEBHOOK_SECRET`, the event is routed by type into a per-event Slack message in Block Kit format.\n\n**Event types handled:**\n- `checkout.session.completed` (new paid customer)\n- `customer.subscription.created`\n- `customer.subscription.updated` (plan change)\n- `customer.subscription.deleted` (cancellation)\n- `invoice.payment_failed`\n\nUnknown event types are logged and a generic notification is sent.",
"height": 300,
"width": 400,
"color": 6
},
"id": "note-intro",
"name": "Sticky Note - Intro",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-200,
-100
]
},
{
"parameters": {
"content": "### >> SET ME <<\n\n1. In Stripe Dashboard, Developers, Webhooks: add a new endpoint pointing to this n8n webhook URL.\n2. Subscribe to events: `checkout.session.completed`, `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.payment_failed`.\n3. Copy the signing secret (starts with `whsec_`) and set as n8n env: `STRIPE_WEBHOOK_SECRET`.\n4. Set `WEBHOOK_INTEGRITY_CHECK_ENABLED=1` to enforce signature verification.\n5. Configure Slack incoming webhook URL in `SLACK_BILLING_WEBHOOK`.",
"height": 280,
"width": 380,
"color": 5
},
"id": "note-setup",
"name": "Sticky Note - Setup",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-200,
220
]
},
{
"parameters": {
"httpMethod": "POST",
"path": "stripe-webhook",
"responseMode": "responseNode",
"options": {
"rawBody": true
}
},
"id": "stripe-1-trigger",
"name": "Stripe Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
240,
60
]
},
{
"parameters": {
"jsCode": "// Stripe-specific HMAC signature verification.\n// Stripe signs webhooks as `t=<timestamp>,v1=<hmac-sha256>`.\n// We verify the signature AND a 5-minute replay-window.\n//\n// Enable by setting STRIPE_WEBHOOK_SECRET (whsec_...) AND\n// WEBHOOK_INTEGRITY_CHECK_ENABLED=1.\n\nconst secret = $env.STRIPE_WEBHOOK_SECRET;\nconst integrityCheckEnabled = $env.WEBHOOK_INTEGRITY_CHECK_ENABLED === '1';\n\nif (!secret || !integrityCheckEnabled) {\n return [$input.first()];\n}\n\nconst crypto = require('crypto');\nconst item = $input.first();\nconst rawBody = item.json.rawBody || '';\nconst sigHeader = (item.json.headers && (item.json.headers['stripe-signature'] || item.json.headers['Stripe-Signature'])) || '';\n\nif (!sigHeader || !rawBody) {\n throw new Error('UNAUTHORIZED: missing Stripe-Signature or rawBody');\n}\n\n// Parse `t=<ts>,v1=<sig>[,v0=<...>]` shape\nconst pairs = String(sigHeader).split(',').map(p => p.trim());\nlet timestamp = null;\nconst v1Sigs = [];\nfor (const p of pairs) {\n const [k, ...rest] = p.split('=');\n const v = rest.join('=');\n if (k === 't') timestamp = v;\n else if (k === 'v1') v1Sigs.push(v);\n}\n\nif (!timestamp || v1Sigs.length === 0) {\n throw new Error('UNAUTHORIZED: malformed Stripe-Signature header');\n}\n\n// Replay-window: reject events older than 5 minutes\nconst REPLAY_WINDOW_S = 300;\nconst tsNum = parseInt(timestamp, 10);\nif (!Number.isFinite(tsNum) || Math.abs(Math.floor(Date.now() / 1000) - tsNum) > REPLAY_WINDOW_S) {\n throw new Error('UNAUTHORIZED: timestamp outside replay window');\n}\n\n// Compute expected v1 = HMAC-SHA256(secret, timestamp + '.' + rawBody)\nconst signedPayload = timestamp + '.' + rawBody;\nconst expected = crypto.createHmac('sha256', secret).update(signedPayload, 'utf8').digest('hex');\nconst expectedBuf = Buffer.from(expected, 'utf8');\n\n// Constant-time compare against any of the v1 signatures (Stripe rotates)\nlet match = false;\nfor (const sig of v1Sigs) {\n if (sig.length !== expected.length) continue; // length-guard\n const sigBuf = Buffer.from(sig, 'utf8');\n if (crypto.timingSafeEqual(expectedBuf, sigBuf)) {\n match = true;\n break;\n }\n}\n\nif (!match) {\n throw new Error('UNAUTHORIZED: invalid signature');\n}\n\nreturn [$input.first()];"
},
"id": "stripe-pp-1-verify",
"name": "Verify Stripe Signature (opt-in)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
440,
60
]
},
{
"parameters": {
"jsCode": "// Per-customer-id sliding-window rate limit, opt-in.\n// 60 events per 5 minutes per customer. Defends against a leaked\n// signing-secret being used to spam events.\n\nif ($env.RATE_LIMIT_ENABLED !== '1') {\n return [$input.first()];\n}\n\nconst LIMIT = 60;\nconst WINDOW_MS = 5 * 60 * 1000;\nconst MAX_KEYS = 5000;\n\nconst event = ($input.first().json.body || {});\nconst customer = (event.data && event.data.object && event.data.object.customer) || 'unknown';\nconst key = String(customer);\n\nconst data = $getWorkflowStaticData('global');\ndata.rateBuckets = data.rateBuckets || {};\nconst buckets = data.rateBuckets;\nconst now = Date.now();\n\nfor (const k of Object.keys(buckets)) {\n buckets[k] = (buckets[k] || []).filter(t => now - t < WINDOW_MS);\n if (buckets[k].length === 0) delete buckets[k];\n}\nif (Object.keys(buckets).length > MAX_KEYS) {\n const oldest = Object.entries(buckets).sort((a,b) => (a[1][0]||0) - (b[1][0]||0)).slice(0, 100);\n for (const [k] of oldest) delete buckets[k];\n}\n\nconst hits = buckets[key] || [];\nif (hits.length >= LIMIT) {\n throw new Error('RATE_LIMIT_EXCEEDED: ' + LIMIT + ' events per ' + Math.round(WINDOW_MS / 60000) + ' minutes for customer ' + key);\n}\nbuckets[key] = [...hits, now];\n\nreturn [$input.first()];"
},
"id": "stripe-pp-2-ratelimit",
"name": "Rate Limit (opt-in)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
640,
60
]
},
{
"parameters": {
"jsCode": "// Idempotency on Stripe event.id. Stripe retries failed webhook deliveries\n// up to 3 days. Without dedup we send duplicate Slack messages per retry.\n\nif ($env.IDEMPOTENCY_ENABLED !== '1') {\n return [$input.first()];\n}\n\nconst WINDOW_MS = 24 * 60 * 60 * 1000; // 24h, Stripe retries within 3d\nconst MAX_KEYS = 50000;\n\nconst event = ($input.first().json.body || {});\nconst eventId = event.id;\n\nif (!eventId) {\n return [$input.first()];\n}\n\nconst data = $getWorkflowStaticData('global');\ndata.seenKeys = data.seenKeys || {};\nconst seen = data.seenKeys;\nconst now = Date.now();\n\nfor (const k of Object.keys(seen)) {\n if (now - seen[k] > WINDOW_MS) delete seen[k];\n}\nif (Object.keys(seen).length > MAX_KEYS) {\n const oldest = Object.entries(seen).sort((a,b) => a[1] - b[1]).slice(0, 5000);\n for (const [k] of oldest) delete seen[k];\n}\n\nif (seen[eventId]) {\n // Duplicate detected. Emit a sentinel item that the\n // 'Skip If Duplicate' IF node routes to 'Respond Duplicate'\n // (200 OK + { deduped: true }). Without that 200 the source\n // provider would hold the HTTP connection until n8n's webhook\n // timeout (default 30s) and mark delivery failed.\n return [{ json: { skipped: true, reason: 'duplicate', dedupKey: String(eventId) } }];\n}\nseen[eventId] = now;\n\nreturn [$input.first()];"
},
"id": "stripe-pp-3-idempotency",
"name": "Idempotency Check (opt-in)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
840,
60
]
},
{
"parameters": {
"jsCode": "// Normalize the Stripe event into a stable shape for the Slack-message builder.\n// Pull customer, amount, plan, status across the supported event types.\n\nconst event = ($input.first().json.body || {});\nconst type = event.type || 'unknown';\nconst obj = (event.data && event.data.object) || {};\n\nconst customer = obj.customer || obj.customer_email || 'unknown';\nconst customerEmail = (obj.customer_details && obj.customer_details.email) || obj.customer_email || obj.receipt_email || null;\nconst customerName = (obj.customer_details && obj.customer_details.name) || null;\nconst country = (obj.customer_details && obj.customer_details.address && obj.customer_details.address.country) || null;\n\n// Amount in major currency (e.g. EUR, USD)\nconst amountMinor = obj.amount_total ?? obj.amount_due ?? obj.amount_paid ?? null;\nconst currency = (obj.currency || 'usd').toUpperCase();\nconst amount = amountMinor != null ? (amountMinor / 100).toFixed(2) : null;\n\nconst plan = (obj.metadata && obj.metadata.plan)\n || (obj.items && obj.items.data && obj.items.data[0] && obj.items.data[0].price && obj.items.data[0].price.nickname)\n || null;\n\nconst status = obj.status || obj.payment_status || null;\nconst subscriptionId = obj.subscription || (obj.id && obj.id.startsWith('sub_') ? obj.id : null);\n\nreturn [{\n json: {\n eventId: event.id,\n eventType: type,\n customer,\n customerEmail,\n customerName,\n country,\n amount,\n currency,\n plan,\n status,\n subscriptionId,\n livemode: !!event.livemode,\n receivedAt: new Date().toISOString(),\n raw: event,\n },\n}];"
},
"id": "stripe-1-normalize",
"name": "Normalize Event",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1040,
60
]
},
{
"parameters": {
"jsCode": "// Build a Slack Block Kit message tuned per event type.\n// Falls through to a generic message for unknown types.\n\nconst e = $input.first().json;\nconst envEmoji = e.livemode ? ':white_check_mark:' : ':warning: TEST';\nconst customerLine = e.customerName\n ? `${e.customerName} (${e.customerEmail || e.customer})`\n : (e.customerEmail || e.customer);\n\nconst amountLine = e.amount ? `*${e.amount} ${e.currency}*` : null;\nconst planLine = e.plan ? `Plan: *${e.plan}*` : null;\n\nlet header = '';\nlet color = '#36a64f';\n\nswitch (e.eventType) {\n case 'checkout.session.completed':\n header = `${envEmoji} New paid customer: ${customerLine}`;\n color = '#36a64f';\n break;\n case 'customer.subscription.created':\n header = `${envEmoji} Subscription started: ${customerLine}`;\n color = '#2eb886';\n break;\n case 'customer.subscription.updated':\n header = `:arrows_counterclockwise: Subscription updated: ${customerLine}`;\n color = '#daa038';\n break;\n case 'customer.subscription.deleted':\n header = `:warning: Subscription cancelled: ${customerLine}`;\n color = '#d93f0b';\n break;\n case 'invoice.payment_failed':\n header = `:rotating_light: Payment failed: ${customerLine}`;\n color = '#a30200';\n break;\n default:\n header = `:bell: Stripe event ${e.eventType}: ${customerLine}`;\n color = '#666666';\n break;\n}\n\nconst fields = [];\nif (amountLine) fields.push({ type: 'mrkdwn', text: amountLine });\nif (planLine) fields.push({ type: 'mrkdwn', text: planLine });\nif (e.country) fields.push({ type: 'mrkdwn', text: `Country: *${e.country}*` });\nif (e.status) fields.push({ type: 'mrkdwn', text: `Status: *${e.status}*` });\n\nconst blocks = [\n {\n type: 'section',\n text: { type: 'mrkdwn', text: `*${header}*` },\n },\n];\n\nif (fields.length > 0) {\n blocks.push({ type: 'section', fields });\n}\n\nblocks.push({\n type: 'context',\n elements: [\n { type: 'mrkdwn', text: `Event \\`${e.eventId}\\` at ${e.receivedAt}` },\n ],\n});\n\nconst payload = {\n text: header, // fallback for notifications\n attachments: [{ color, blocks }],\n};\n\nreturn [{ json: payload }];"
},
"id": "stripe-2-build-message",
"name": "Build Slack Message",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1240,
60
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.SLACK_BILLING_WEBHOOK }}",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify($json) }}",
"options": {}
},
"id": "stripe-3-slack",
"name": "Slack Billing Notification",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1440,
60
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ ok: true }) }}",
"options": {
"responseCode": 200
}
},
"id": "stripe-4-respond",
"name": "Respond to Stripe",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1640,
60
]
},
{
"parameters": {
"jsCode": "// Fallback for Slack webhook failures. Log a structured error and respond\n// to Stripe with 200 so Stripe does not retry. (We could also respond 500\n// to trigger Stripe retry, but for Slack-delivery failures we accept the\n// loss and surface it via the n8n execution log + ops alert.)\n\nconst err = ($input.first().json && $input.first().json.error) || {};\nconst event = ($('Normalize Event').first() && $('Normalize Event').first().json) || {};\n\nreturn [{\n json: {\n ok: false,\n fallback: true,\n eventId: event.eventId || 'unknown',\n eventType: event.eventType || 'unknown',\n error: {\n message: err.message || 'unknown error',\n name: err.name || 'SlackDeliveryError',\n },\n receivedAt: event.receivedAt || new Date().toISOString(),\n },\n}];"
},
"id": "stripe-err-fallback",
"name": "Error Fallback",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1440,
360
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ ok: true, deliveryDeferred: true }) }}",
"options": {
"responseCode": 200
}
},
"id": "stripe-err-respond",
"name": "Error Respond to Stripe",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1640,
360
]
},
{
"parameters": {
"content": "## Production Patterns\n\n- **Verify Stripe Signature (opt-in):** parses `Stripe-Signature` header (`t=<ts>,v1=<hmac>`), checks 5-min replay window, constant-time-compares HMAC-SHA256(rawBody) against `STRIPE_WEBHOOK_SECRET`.\n- **Rate Limit (opt-in):** 60 events / 5 min / customer. Defends against leaked-secret spam.\n- **Idempotency (opt-in):** 24h dedup on Stripe `event.id`. Stripe retries up to 3 days. On duplicate the Idempotency Check emits a `{ skipped: true }` sentinel that the `Skip If Duplicate` IF node routes to the dedicated `Respond Duplicate` `respondToWebhook` node (200 OK + `{ deduped: true }`). Stripe sees a clean 200 instead of a 30s connection-hang, no \"delivery failed\" alerts.\n- **Error branch (always on):** Slack delivery failure does not crash the webhook, returns 200 to Stripe so it does not retry, surfaces via n8n logs.",
"height": 280,
"width": 380,
"color": 7
},
"id": "note-production-patterns",
"name": "Sticky Note - Production Patterns",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
840,
-260
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "cond-02-stripe-lifecycle-to-slack-skipped",
"leftValue": "={{ $json.skipped }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "02-str-if-skip-dup",
"name": "Skip If Duplicate",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
1060,
60
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ ok: true, deduped: true, reason: \"duplicate\" }) }}",
"options": {
"responseCode": 200,
"responseHeaders": {
"entries": [
{
"name": "X-Dedup",
"value": "1"
}
]
}
}
},
"id": "02-str-respond-duplicate",
"name": "Respond Duplicate",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1280,
-120
]
}
],
"connections": {
"Stripe Webhook": {
"main": [
[
{
"node": "Verify Stripe Signature (opt-in)",
"type": "main",
"index": 0
}
]
]
},
"Verify Stripe Signature (opt-in)": {
"main": [
[
{
"node": "Rate Limit (opt-in)",
"type": "main",
"index": 0
}
]
]
},
"Rate Limit (opt-in)": {
"main": [
[
{
"node": "Idempotency Check (opt-in)",
"type": "main",
"index": 0
}
]
]
},
"Idempotency Check (opt-in)": {
"main": [
[
{
"node": "Skip If Duplicate",
"type": "main",
"index": 0
}
]
]
},
"Normalize Event": {
"main": [
[
{
"node": "Build Slack Message",
"type": "main",
"index": 0
}
]
]
},
"Build Slack Message": {
"main": [
[
{
"node": "Slack Billing Notification",
"type": "main",
"index": 0
}
]
]
},
"Slack Billing Notification": {
"main": [
[
{
"node": "Respond to Stripe",
"type": "main",
"index": 0
}
],
[
{
"node": "Error Fallback",
"type": "main",
"index": 0
}
]
]
},
"Error Fallback": {
"main": [
[
{
"node": "Error Respond to Stripe",
"type": "main",
"index": 0
}
]
]
},
"Skip If Duplicate": {
"main": [
[
{
"node": "Respond Duplicate",
"type": "main",
"index": 0
}
],
[
{
"node": "Normalize Event",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
How this works
This workflow instantly notifies your team on Slack whenever a key event occurs in your Stripe account, such as a new subscription or payment failure, ensuring everyone stays aligned without constant manual checks. It's ideal for e-commerce managers or SaaS operators who rely on Stripe for billing and want real-time visibility into revenue flows. The core step involves receiving and verifying the signed webhook from Stripe, normalising the event data, then crafting and dispatching a tailored message to your chosen Slack channel via HTTP requests.
Use this when handling high-volume Stripe events that demand immediate team awareness, like monitoring subscription lifecycles in a growing business. Avoid it for low-traffic sites where email summaries suffice, or if you prefer batched notifications to prevent Slack overload. Common variations include filtering for specific event types, such as only successful payments, or routing messages to different channels based on event severity.
About this workflow
Stripe Lifecycle to Slack (Signed Webhooks, Per-Event Messages). Uses stickyNote, httpRequest, respondToWebhook. Webhook trigger; 15 nodes.
Source: https://github.com/studiomeyer-io/n8n-workflows/blob/main/templates/02-stripe-lifecycle-to-slack/workflow.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.
PUQ Docker NextCloud deploy. Uses respondToWebhook, stickyNote, httpRequest, ssh. Webhook trigger; 44 nodes.
Analyze_email_headers_for_IPs_and_spoofing__3. Uses stickyNote, respondToWebhook, itemLists, httpRequest. Webhook trigger; 35 nodes.
Unique QRcode coupon assignment and validation for Lead Generation system. Uses httpRequest, formTrigger, googleSheets, stickyNote. Webhook trigger; 29 nodes.
Line Save File to Google Drive and Log File's URL. Uses googleSheets, googleDrive, httpRequest, stickyNote. Webhook trigger; 27 nodes.
Webhook Respondtowebhook. Uses executeWorkflow, httpRequest, stickyNote, respondToWebhook. Webhook trigger; 23 nodes.