# Post prospect replies to Slack

A small server that receives the prospect_response webhook, verifies its signature, skips duplicates, and posts the reply to a Slack channel.

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

When a prospect replies to a campaign, Victoria AI sends a [`prospect_response`](https://docs.versionseven.ai/api-reference/webhooks/prospect-response) event to the campaign's webhooks. This recipe is a small server that receives the event and posts it to a Slack channel, so your team sees replies without opening Victoria AI.

## Before you start

- A Slack [incoming webhook](https://api.slack.com/messaging/webhooks) URL for the channel, in the `SLACK_WEBHOOK_URL` environment variable.
- A signing secret of your own, at least 16 characters, in `VICTORIA_WEBHOOK_SECRET`. One way to make one is `openssl rand -hex 32`.
- A public HTTPS address for the server. Victoria AI doesn't deliver to `localhost` or private addresses.
- Node.js 18 or later with `express`, or Python 3.10 or later with `flask` and `requests`.

## 1. Run the receiver

**Node.js (Express)**

```javascript
// server.mjs
import crypto from "node:crypto";
import express from "express";

const secret = process.env.VICTORIA_WEBHOOK_SECRET;
const slackWebhookUrl = process.env.SLACK_WEBHOOK_URL;
// Keys already posted. In production, store them in a database or Redis.
const processed = new Set();

function isValidSignature(rawBody, header) {
  if (typeof header !== "string") return false;
  const digest = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  const expected = Buffer.from(`sha256=${digest}`);
  const received = Buffer.from(header);
  return expected.length === received.length && crypto.timingSafeEqual(expected, received);
}

// Slack treats &, < and > as control characters in message text.
function escapeForSlack(text) {
  return String(text).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}

function slackMessage(event) {
  const lead = event.lead ?? {};
  const name = [lead.first_name, lead.last_name].filter(Boolean).join(" ") || "A prospect";
  const who = lead.company ? `${name} (${lead.company})` : name;
  const channel = event.channel === "linkedin" ? "on LinkedIn" : "by email";
  const reply = event.prospect_message ?? "";
  const excerpt = reply.length > 1000 ? `${reply.slice(0, 1000)}…` : reply;
  const sentiment = event.ai_response?.sentiment;

  const campaign = escapeForSlack(event.campaign ?? "a campaign");
  const lines = [`*${escapeForSlack(who)}* replied ${channel} to *${campaign}*`];
  if (sentiment) lines.push(`Sentiment: ${escapeForSlack(sentiment)}`);
  lines.push(...escapeForSlack(excerpt).split("\n").map((line) => `> ${line}`));
  return { text: lines.join("\n") };
}

const app = express();
// express.raw keeps the body as the exact bytes that were signed.
const rawJson = express.raw({ type: "application/json" });

app.post("/victoria/webhooks", rawJson, async (req, res) => {
  if (!isValidSignature(req.body, req.get("X-Signature-256"))) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString("utf8"));
  if (event.event !== "prospect_response" || processed.has(event.idempotency_key)) {
    return res.sendStatus(200);
  }

  try {
    const slack = await fetch(slackWebhookUrl, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(slackMessage(event)),
      signal: AbortSignal.timeout(10_000),
    });
    if (!slack.ok) throw new Error(`Slack answered ${slack.status}`);
  } catch (error) {
    console.error(`Couldn't post to Slack: ${error.message}`);
    // An error status marks the delivery as failed, so Victoria AI can retry it.
    return res.sendStatus(502);
  }

  processed.add(event.idempotency_key);
  res.sendStatus(200);
});

app.listen(3000, () => console.log("Listening on port 3000"));
```

**Python (Flask)**

```python
# server.py
import hashlib
import hmac
import os

import requests
from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["VICTORIA_WEBHOOK_SECRET"].encode()
SLACK_WEBHOOK_URL = os.environ["SLACK_WEBHOOK_URL"]
# Keys already posted. In production, store them in a database or Redis.
processed = set()


def is_valid_signature(raw_body: bytes, header: str | None) -> bool:
    if not header:
        return False
    expected = "sha256=" + hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header)


def escape_for_slack(text) -> str:
    # Slack treats &, < and > as control characters in message text.
    return str(text).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")


def slack_message(event: dict) -> dict:
    lead = event.get("lead") or {}
    parts = (lead.get("first_name"), lead.get("last_name"))
    name = " ".join(part for part in parts if part) or "A prospect"
    who = f"{name} ({lead['company']})" if lead.get("company") else name
    channel = "on LinkedIn" if event.get("channel") == "linkedin" else "by email"
    reply = event.get("prospect_message") or ""
    excerpt = reply[:1000] + "…" if len(reply) > 1000 else reply
    sentiment = (event.get("ai_response") or {}).get("sentiment")

    campaign = event.get("campaign") or "a campaign"
    lines = [f"*{escape_for_slack(who)}* replied {channel} to *{escape_for_slack(campaign)}*"]
    if sentiment:
        lines.append(f"Sentiment: {escape_for_slack(sentiment)}")
    lines.extend(f"> {line}" for line in escape_for_slack(excerpt).split("\n"))
    return {"text": "\n".join(lines)}


@app.post("/victoria/webhooks")
def victoria_webhook():
    raw_body = request.get_data()  # the exact bytes that were signed
    if not is_valid_signature(raw_body, request.headers.get("X-Signature-256")):
        abort(401)

    event = request.get_json()
    if event.get("event") != "prospect_response" or event.get("idempotency_key") in processed:
        return "", 200

    try:
        slack = requests.post(SLACK_WEBHOOK_URL, json=slack_message(event), timeout=10)
        slack.raise_for_status()
    except requests.RequestException as error:
        app.logger.error("Couldn't post to Slack: %s", error)
        # An error status marks the delivery as failed, so Victoria AI can retry it.
        abort(502)

    processed.add(event["idempotency_key"])
    return "", 200
```

Start it on port 3000 with `node server.mjs`, or `flask --app server run --port 3000`. In production, run the Flask app under a WSGI server such as Gunicorn.

The receiver:

- Rejects a request whose `X-Signature-256` doesn't match the raw body with `401`. See [Verifying signatures](https://docs.versionseven.ai/guides/verifying-webhooks).
- Skips an event it has already posted. Retries of a delivery carry the same `idempotency_key`.
- Posts to Slack before it answers. Victoria AI waits up to 75 seconds for a response, and the Slack request gives up after 10.
- Answers `502` when Slack fails, so the delivery counts as failed and can be retried.

## 2. Register the webhook

Register the receiver's URL on the campaign with [`POST /v1/campaigns/{campaign_id}/webhooks`](https://docs.versionseven.ai/api-reference/campaigns/create-webhook), sending your secret:

```bash
curl -X POST https://api.versionseven.ai/v1/campaigns/550e8400-e29b-41d4-a716-446655440000/webhooks \
  -H "Authorization: Bearer $VICTORIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"webhook_url\": \"https://replies.example.com/victoria/webhooks\", \"secret\": \"$VICTORIA_WEBHOOK_SECRET\"}"
```

A `201` means the webhook is created and already active. Because you sent the secret, the response doesn't include one. Webhooks belong to one campaign, so register the URL on each campaign whose replies you want in Slack.

## 3. Send a test delivery

Before a real reply arrives, sign a payload yourself and send it to the receiver. This one is shortened; [`GET /v1/webhooks/examples`](https://docs.versionseven.ai/api-reference/reference/list-webhook-examples) returns complete examples.

```bash
BODY='{"event":"prospect_response","idempotency_key":"f215faf9d88b7f0a881632ee22459ee452a296c808d261b6cc993d3a1fd0600e","campaign":"Q3 outbound","channel":"email","lead":{"first_name":"Sarah","last_name":"Johnson","company":"Acme Corp"},"prospect_message":"Sounds interesting. Could you send over some times next week?","ai_response":{"sentiment":"positive"}}'
SIGNATURE=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$VICTORIA_WEBHOOK_SECRET" | sed 's/^.* //')

curl -X POST http://localhost:3000/victoria/webhooks \
  -H "Content-Type: application/json" \
  -H "X-Signature-256: sha256=$SIGNATURE" \
  --data "$BODY"
```

The message appears in Slack. Send the same request again and nothing new is posted, because the key was already processed. Change a character in `BODY` without signing it again, and the receiver answers `401`.

## What to expect from deliveries

- A lead's reply is normally delivered once per campaign, not for every message in the conversation. If a later reply is positive after an earlier one wasn't, it's delivered again with a new `idempotency_key`.
- `campaign` is the campaign's name; the payload doesn't include its ID.
- `ai_response.sentiment` is `null` when the reply wasn't analyzed, so the message leaves the sentiment line out. Lead fields without a value are `null` too.
- A failed delivery is retried every 15 minutes, up to 5 attempts within 48 hours.

> **Note:** Retries only happen when every webhook on the campaign failed. If another webhook on the same campaign accepted the delivery, a failed Slack post isn't retried.

The [`prospect_response` reference](https://docs.versionseven.ai/api-reference/webhooks/prospect-response) documents every field.
