Route prospect replies and add leads with n8n
Two importable n8n workflows for Victoria AI, a reply hub that verifies the webhook signature and routes replies by sentiment, and one that adds leads.
Last updated
- n8n
- Slack
- Google Sheets
n8n has no Victoria AI node, and doesn't need one: its Webhook, Crypto and HTTP Request nodes cover the REST API and the prospect_response webhook. This recipe is two workflows you import as JSON. The reply hub receives every reply to a campaign, verifies the signature against the raw body, and routes each reply to Slack or a sheet by sentiment. The leads workflow adds a person to a campaign when a row lands in a sheet, and handles each answer the API can give.
Before you start
- n8n 1.x or later, cloud or self-hosted, reachable from the internet on HTTPS (the webhook URL must be public).
- An API key with
campaigns:write(to register the webhook) andleads:write(for the leads workflow). See Create an API key. - A signing secret of your own, at least 16 characters. One way to make one is
openssl rand -hex 32. - The campaign's id, from its URL in the app or from
GET /v1/campaigns. - For the routes: a Slack credential and a Google Sheets credential in n8n. The import leaves credentials empty; you pick yours on each node.
Workflow 1: the reply hub
A campaign has one webhook, so this workflow is the only receiver for the campaign's replies. Add routes inside it rather than registering a second workflow.
How it works
- Webhook receives the
POST. Its Raw Body option keeps the exact bytes that were signed in a binary property calleddata, alongside the parsedbodyandheaders. - Crypto computes the HMAC-SHA256 of that binary property with the Hmac Secret from a Crypto credential, as hex, into
signature. - If compares
sha256=plus that hash with thex-signature-256header. A mismatch ends the run; nothing downstream sees an unsigned request. - Switch routes on
body.ai_response.sentiment: positive replies go to Slack, every reply goes to Google Sheets, and hand-offs (agent_actionisescalate) go to whatever you connect to the third output.
The Webhook node answers 200 as soon as it receives the request (Respond: Immediately), so Victoria AI treats the delivery as done; a route that fails shows up in n8n's execution list, where you can retry it.
Import it
In n8n, open a new workflow, choose Import from File in the menu (or paste the JSON straight onto the canvas) and save.
{
"name": "Victoria AI: reply hub",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "victoria-replies",
"responseMode": "onReceived",
"options": { "rawBody": true }
},
"name": "Reply arrives",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [0, 300],
"webhookId": "victoria-replies"
},
{
"parameters": {
"action": "hmac",
"binaryData": true,
"binaryPropertyName": "data",
"type": "SHA256",
"dataPropertyName": "signature",
"encoding": "hex"
},
"name": "Sign the raw body",
"type": "n8n-nodes-base.crypto",
"typeVersion": 2,
"position": [220, 300]
},
{
"parameters": {
"conditions": {
"options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 },
"combinator": "and",
"conditions": [
{
"id": "signature-matches",
"leftValue": "={{ 'sha256=' + $json.signature }}",
"rightValue": "={{ $json.headers['x-signature-256'] }}",
"operator": { "type": "string", "operation": "equals" }
}
]
},
"options": {}
},
"name": "Signature valid?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [440, 300]
},
{
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 },
"combinator": "and",
"conditions": [
{
"id": "positive",
"leftValue": "={{ $json.body.ai_response.sentiment }}",
"rightValue": "positive",
"operator": { "type": "string", "operation": "equals" }
}
]
},
"renameOutput": true,
"outputKey": "Positive"
},
{
"conditions": {
"options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 },
"combinator": "and",
"conditions": [
{
"id": "handoff",
"leftValue": "={{ $json.body.ai_response.agent_action }}",
"rightValue": "escalate",
"operator": { "type": "string", "operation": "equals" }
}
]
},
"renameOutput": true,
"outputKey": "Hand-off"
}
]
},
"options": { "allMatchingOutputs": true, "fallbackOutput": "extra" }
},
"name": "Route by sentiment",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [660, 300]
},
{
"parameters": {
"select": "channel",
"channelId": { "__rl": true, "mode": "name", "value": "#replies" },
"text": "=*{{ $json.body.lead.first_name }} {{ $json.body.lead.last_name }}* ({{ $json.body.lead.company }}) replied by {{ $json.body.channel }} to *{{ $json.body.campaign }}*\n> {{ $json.body.prospect_message }}",
"otherOptions": {}
},
"name": "Post to Slack",
"type": "n8n-nodes-base.slack",
"typeVersion": 2.2,
"position": [900, 160]
},
{
"parameters": {
"operation": "append",
"documentId": { "__rl": true, "mode": "url", "value": "" },
"sheetName": { "__rl": true, "mode": "name", "value": "Replies" },
"columns": {
"mappingMode": "defineBelow",
"value": {
"Received at": "={{ $now.toISO() }}",
"Campaign": "={{ $json.body.campaign }}",
"Channel": "={{ $json.body.channel }}",
"Sentiment": "={{ $json.body.ai_response.sentiment }}",
"First name": "={{ $json.body.lead.first_name }}",
"Last name": "={{ $json.body.lead.last_name }}",
"Company": "={{ $json.body.lead.company }}",
"Email": "={{ $json.body.lead.email }}",
"Message": "={{ $json.body.prospect_message }}",
"Idempotency key": "={{ $json.body.idempotency_key }}"
},
"matchingColumns": [],
"schema": []
},
"options": {}
},
"name": "Append to sheet",
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.5,
"position": [900, 440]
}
],
"connections": {
"Reply arrives": { "main": [[{ "node": "Sign the raw body", "type": "main", "index": 0 }]] },
"Sign the raw body": { "main": [[{ "node": "Signature valid?", "type": "main", "index": 0 }]] },
"Signature valid?": { "main": [[{ "node": "Route by sentiment", "type": "main", "index": 0 }], []] },
"Route by sentiment": {
"main": [
[{ "node": "Post to Slack", "type": "main", "index": 0 }, { "node": "Append to sheet", "type": "main", "index": 0 }],
[{ "node": "Append to sheet", "type": "main", "index": 0 }],
[{ "node": "Append to sheet", "type": "main", "index": 0 }]
]
}
},
"settings": { "executionOrder": "v1" }
}After the import:
- Open Sign the raw body and create a Crypto credential with your signing secret as its Hmac Secret. The secret lives in the credential, not in the workflow, so the JSON above can be shared.
- Open Post to Slack, pick your Slack credential and the channel. Open Append to sheet, pick your Google Sheets credential and the spreadsheet; create a tab called
Replieswith the ten column headers used in the node, in that order. - Connect whatever should handle hand-offs to the Switch's Hand-off output: an email node, a Linear or Asana node, or another Slack message. The third output (Fallback) carries every other reply; the sheet is connected to all three so every reply is logged once.
- Activate the workflow and copy the Production URL from the Webhook node. It ends in
/webhook/victoria-replies.
Register and test
Register the production URL on the campaign with POST /v1/campaigns/{campaign_id}/webhooks, sending the same secret. replace: true repoints the campaign's one webhook here if something else held it:
curl -X POST https://api.versionseven.ai/v1/campaigns/$CAMPAIGN_ID/webhooks \
-H "Authorization: Bearer $VICTORIA_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"webhook_url\": \"https://n8n.example.com/webhook/victoria-replies\", \"secret\": \"$VICTORIA_WEBHOOK_SECRET\", \"replace\": true}"Then send a signed example from GET /v1/webhooks/examples:
BODY=$(curl -s https://api.versionseven.ai/v1/webhooks/examples -H "Authorization: Bearer $VICTORIA_API_KEY" | python3 -c 'import json,sys; print(json.dumps(json.load(sys.stdin)["examples"][0]))')
SIGNATURE=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$VICTORIA_WEBHOOK_SECRET" | sed 's/^.* //')
curl -X POST https://n8n.example.com/webhook/victoria-replies \
-H "Content-Type: application/json" \
-H "X-Signature-256: sha256=$SIGNATURE" \
--data "$BODY"The execution shows the Crypto node's signature, the If node taking its true branch, the Switch sending the positive example to Slack and the sheet. Change one character of BODY, send it without re-signing, and the If node takes its false branch: nothing is posted. While you build, the Webhook node's Test URL (/webhook-test/…) takes the same request and shows the data on the canvas.
Workflow 2: add leads from a sheet
The trigger is Google Sheets Trigger, Row Added; swap in any trigger that produces a person. The HTTP Request node posts to POST /v1/leads with an Idempotency-Key derived from the campaign and the person, so a re-run can't enrol anyone twice. Create a Header Auth credential in n8n with name Authorization and value Bearer vk_…, and pick it on the HTTP Request node.
{
"name": "Victoria AI: leads from a sheet",
"nodes": [
{
"parameters": {
"pollTimes": { "item": [{ "mode": "everyMinute" }] },
"documentId": { "__rl": true, "mode": "url", "value": "" },
"sheetName": { "__rl": true, "mode": "name", "value": "Leads" },
"event": "rowAdded",
"options": {}
},
"name": "Row added",
"type": "n8n-nodes-base.googleSheetsTrigger",
"typeVersion": 1,
"position": [0, 300]
},
{
"parameters": {
"method": "POST",
"url": "https://api.versionseven.ai/v1/leads",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Idempotency-Key",
"value": "={{ $('Row added').item.json['Campaign ID'] + ':' + ($json.Email || $json['LinkedIn URL']).toLowerCase().trim() }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ campaign_id: $json['Campaign ID'], lead: { first_name: $json['First name'], last_name: $json['Last name'], email: $json.Email || undefined, linkedin_url: $json['LinkedIn URL'] || undefined, company: $json.Company || undefined, title: $json.Title || undefined } }) }}",
"options": { "response": { "response": { "neverError": true, "fullResponse": true } } }
},
"name": "Add the lead",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [240, 300]
},
{
"parameters": {
"conditions": {
"options": { "caseSensitive": true, "leftValue": "", "typeValidation": "loose", "version": 2 },
"combinator": "or",
"conditions": [
{
"id": "created",
"leftValue": "={{ $json.statusCode }}",
"rightValue": 201,
"operator": { "type": "number", "operation": "equals" }
},
{
"id": "enrolled",
"leftValue": "={{ $json.statusCode }}",
"rightValue": 200,
"operator": { "type": "number", "operation": "equals" }
}
]
},
"options": {}
},
"name": "Added?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [480, 300]
}
],
"connections": {
"Row added": { "main": [[{ "node": "Add the lead", "type": "main", "index": 0 }]] },
"Add the lead": { "main": [[{ "node": "Added?", "type": "main", "index": 0 }]] }
},
"settings": { "executionOrder": "v1" }
}The sheet needs columns Campaign ID, First name, Last name, Email, LinkedIn URL, Company and Title; a row needs a first and last name and an email or LinkedIn URL. The HTTP Request node is set to never error and to return the full response, so the API's answer is data for the next node rather than a failed execution:
statusCode and body.error | Meaning |
|---|---|
201 | A new lead was created and enrolled; body.lead_id is its id. |
200 | The person was already a lead in your organization and was enrolled. |
409 LEAD_ALREADY_IN_CAMPAIGN | Already there. Nothing to do. |
409 LEAD_SUPPRESSED | The person is on your Do Not Contact list. Nothing was created. |
400 VALIDATION_ERROR | A required field is missing or malformed; body.details.errors lists each. |
403 TRIAL_LEAD_CAP_REACHED | The organization is on a free trial and has used its lead quota. |
429 RATE_LIMITED | More than 100 requests a minute to the endpoint. Add a Wait node of a second between rows, or turn on Retry On Fail in the node's settings. See Rate limits. |
Connect the Added? node's true output to a Google Sheets, Update Row node that writes body.lead_id back into the row, and its false output to a Slack message or a second update that writes body.error, so the sheet shows what happened to every row.
Next steps
- Receive replies once and fan them out, the same hub as a small server, for teams that would rather run code than a workflow.
- Route prospect replies and add leads with Make and with Zapier, the same two automations elsewhere.
- Receiving webhooks for delivery, retries and the one-webhook rule.