# 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.

Works with: n8n, Slack, Google Sheets.

Endpoints used:

- [`POST /v1/campaigns/{campaign_id}/webhooks`](https://docs.versionseven.ai/api-reference/campaigns/create-webhook) Create a webhook
- [`GET /v1/webhooks/examples`](https://docs.versionseven.ai/api-reference/reference/list-webhook-examples) List webhook examples
- [`POST /v1/leads`](https://docs.versionseven.ai/api-reference/leads/create-lead) Create a lead

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) and `leads:write` (for the leads workflow). See [Create an API key](https://docs.versionseven.ai/help/api-keys).
- 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

1. **Webhook** receives the `POST`. Its **Raw Body** option keeps the exact bytes that were signed in a binary property called `data`, alongside the parsed `body` and `headers`.
2. **Crypto** computes the HMAC-SHA256 of that binary property with the **Hmac Secret** from a Crypto credential, as hex, into `signature`.
3. **If** compares `sha256=` plus that hash with the `x-signature-256` header. A mismatch ends the run; nothing downstream sees an unsigned request.
4. **Switch** routes on `body.ai_response.sentiment`: positive replies go to **Slack**, every reply goes to **Google Sheets**, and hand-offs (`agent_action` is `escalate`) 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.

```json
{
  "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:

1. 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.
2. 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 `Replies` with the ten column headers used in the node, in that order.
3. 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.
4. 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`](https://docs.versionseven.ai/api-reference/campaigns/create-webhook), sending the same secret. `replace: true` repoints the campaign's one webhook here if something else held it:

```bash
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`](https://docs.versionseven.ai/api-reference/reference/list-webhook-examples):

```bash
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`](https://docs.versionseven.ai/api-reference/leads/create-lead) 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.

```json
{
  "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](https://docs.versionseven.ai/help/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](https://docs.versionseven.ai/guides/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](https://docs.versionseven.ai/cookbook/webhook-receiver), 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](https://docs.versionseven.ai/cookbook/make-reply-hub-and-leads) and [with Zapier](https://docs.versionseven.ai/cookbook/zapier-reply-hub-and-leads), the same two automations elsewhere.
- [Receiving webhooks](https://docs.versionseven.ai/guides/webhooks) for delivery, retries and the one-webhook rule.
