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.
Last updated
Endpoints used
When a prospect replies to a campaign, Victoria AI sends a 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 URL for the channel, in the
SLACK_WEBHOOK_URLenvironment variable. - A signing secret of your own, at least 16 characters, in
VICTORIA_WEBHOOK_SECRET. One way to make one isopenssl rand -hex 32. - A public HTTPS address for the server. Victoria AI doesn't deliver to
localhostor private addresses. - Node.js 18 or later with
express, or Python 3.10 or later withflaskandrequests.
1. Run the receiver
// 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, "&").replace(/</g, "<").replace(/>/g, ">");
}
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"));# 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("&", "&").replace("<", "<").replace(">", ">")
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 "", 200Start 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-256doesn't match the raw body with401. See Verifying signatures. - 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
502when 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, sending your secret:
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 returns complete examples.
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. campaignis the campaign's name; the payload doesn't include its ID.ai_response.sentimentisnullwhen the reply wasn't analyzed, so the message leaves the sentiment line out. Lead fields without a value arenulltoo.- A failed delivery is retried every 15 minutes, up to 5 attempts within 48 hours.
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 documents every field.