Start outreach from a product event
An endpoint that turns an event from your product, billing or analytics tool into a lead in the right campaign, with the event's details as custom fields.
Last updated
- Stripe
- PostHog
- Segment
Endpoints used
The best time to reach someone is when they've just done something: signed up and gone quiet, viewed the pricing page twice, or let a trial run down. Your product already knows; this recipe gives it somewhere to send that knowledge. It's an HTTP endpoint that accepts an event, picks the campaign for it, and adds the person as a lead with POST /v1/leads, carrying the event's details as custom fields so the first message can use them. The same event delivered twice adds nothing twice.
Before you start
- An API key with
leads:writeinVICTORIA_API_KEY. See Authentication. - One campaign per event you want to act on. Put each campaign's id in an environment variable; the script maps event names to those variables.
- A shared secret in
EVENT_SECRET, which the caller sends asX-Event-Secret. The caller is your own backend or a tool you control, so a shared secret is enough; see "Calling it from other tools" for providers that sign their webhooks instead. - A public HTTPS address for the endpoint, if the caller is outside your network.
- Node.js 18 or later with
express, or Python 3.10 or later withflaskandrequests.
The event
The endpoint takes one JSON object per request. event picks the campaign; the person's fields become the lead; anything in properties becomes a custom field:
{
"event": "trial_started",
"email": "sarah.johnson@acmecorp.com",
"first_name": "Sarah",
"last_name": "Johnson",
"company": "Acme Corp",
"title": "VP of Sales",
"properties": { "plan": "Team", "signup_date": "2026-10-01" }
}A lead needs first_name, last_name, and email or linkedin_url. Custom fields are usable in a sequence step as {plan} and {signup_date}.
The endpoint
// events.mjs
import { createHash, timingSafeEqual } from "node:crypto";
import express from "express";
const API_URL = "https://api.versionseven.ai/v1";
const API_KEY = process.env.VICTORIA_API_KEY;
const EVENT_SECRET = process.env.EVENT_SECRET;
// Which campaign each event enrols into. Add a line per event you send.
const ROUTES = {
trial_started: process.env.CAMPAIGN_TRIAL_STARTED,
pricing_page_viewed: process.env.CAMPAIGN_HIGH_INTENT,
trial_will_end: process.env.CAMPAIGN_TRIAL_ENDING,
};
const PORT = Number(process.env.PORT ?? 3000);
if (!EVENT_SECRET || EVENT_SECRET.length < 16) throw new Error("EVENT_SECRET must be at least 16 characters");
function isAuthorized(header) {
if (typeof header !== "string") return false;
const expected = Buffer.from(EVENT_SECRET);
const received = Buffer.from(header);
return expected.length === received.length && timingSafeEqual(expected, received);
}
function toLead(event) {
const lead = {};
for (const field of ["first_name", "last_name", "email", "linkedin_url", "company", "title"]) {
if (typeof event[field] === "string" && event[field].trim()) lead[field] = event[field].trim();
}
if (lead.linkedin_url) lead.linkedin_url = lead.linkedin_url.replace(/\/+$/, "");
const properties = event.properties ?? {};
const customFields = { event: event.event };
for (const [key, value] of Object.entries(properties)) {
if (value !== null && value !== undefined && typeof value !== "object") customFields[key] = String(value);
}
lead.custom_fields = customFields;
return lead;
}
// What POST /v1/leads answered, as a word your caller can act on.
async function enrol(campaignId, lead) {
const body = { campaign_id: campaignId, lead };
// The same person, campaign and event always make the same key, so a redelivery can't enrol twice.
const idempotencyKey = createHash("sha256")
.update(`${campaignId}:${lead.custom_fields.event}:${lead.email ?? lead.linkedin_url}`)
.digest("hex");
const response = await fetch(`${API_URL}/leads`, {
method: "POST",
headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": idempotencyKey },
body: JSON.stringify(body),
});
const data = await response.json().catch(() => ({}));
if (response.status === 201) return { outcome: "created", lead_id: data.lead_id };
if (response.status === 200) return { outcome: "enrolled", lead_id: data.lead_id };
switch (data.error) {
case "LEAD_ALREADY_IN_CAMPAIGN":
return { outcome: "already_in_campaign" };
case "LEAD_SUPPRESSED":
return { outcome: "suppressed", detail: `${data.details?.suppressed_by}: ${data.details?.value}` };
case "TRIAL_LEAD_CAP_REACHED":
return { outcome: "trial_cap_reached", detail: `${data.details?.used} of ${data.details?.cap} leads used` };
case "VALIDATION_ERROR":
return { outcome: "invalid", detail: (data.details?.errors ?? []).map((e) => `${e.field} ${e.message}`).join("; ") };
default:
return { outcome: "failed", detail: `${response.status} ${data.error ?? ""} (request_id ${data.request_id ?? "?"})` };
}
}
const app = express();
app.use(express.json());
app.post("/events", async (req, res) => {
if (!isAuthorized(req.get("X-Event-Secret"))) return res.sendStatus(401);
const event = req.body ?? {};
const campaignId = ROUTES[event.event];
if (!campaignId) return res.status(200).json({ outcome: "ignored", reason: `no campaign for event "${event.event}"` });
const result = await enrol(campaignId, toLead(event));
console.log(`${event.event} ${event.email ?? event.linkedin_url ?? "?"} → ${result.outcome}${result.detail ? ` (${result.detail})` : ""}`);
if (result.outcome === "invalid") return res.status(422).json(result);
if (result.outcome === "failed") return res.status(502).json(result); // let the caller retry
res.status(202).json(result);
});
app.listen(PORT, () => console.log(`Listening on port ${PORT}`));# events.py
import hashlib
import hmac
import os
import requests
from flask import Flask, jsonify, request
API_URL = "https://api.versionseven.ai/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['VICTORIA_API_KEY']}"}
EVENT_SECRET = os.environ["EVENT_SECRET"]
# Which campaign each event enrols into. Add a line per event you send.
ROUTES = {
"trial_started": os.environ.get("CAMPAIGN_TRIAL_STARTED"),
"pricing_page_viewed": os.environ.get("CAMPAIGN_HIGH_INTENT"),
"trial_will_end": os.environ.get("CAMPAIGN_TRIAL_ENDING"),
}
if len(EVENT_SECRET) < 16:
raise SystemExit("EVENT_SECRET must be at least 16 characters")
app = Flask(__name__)
def to_lead(event: dict) -> dict:
lead = {}
for field in ("first_name", "last_name", "email", "linkedin_url", "company", "title"):
value = event.get(field)
if isinstance(value, str) and value.strip():
lead[field] = value.strip()
if lead.get("linkedin_url"):
lead["linkedin_url"] = lead["linkedin_url"].rstrip("/")
custom_fields = {"event": event.get("event")}
for key, value in (event.get("properties") or {}).items():
if value is not None and not isinstance(value, (dict, list)):
custom_fields[key] = str(value)
lead["custom_fields"] = custom_fields
return lead
def enrol(campaign_id: str, lead: dict) -> dict:
"""What POST /v1/leads answered, as a word your caller can act on."""
# The same person, campaign and event always make the same key, so a redelivery can't enrol twice.
seed = f"{campaign_id}:{lead['custom_fields']['event']}:{lead.get('email') or lead.get('linkedin_url')}"
idempotency_key = hashlib.sha256(seed.encode()).hexdigest()
response = requests.post(
f"{API_URL}/leads",
headers={**HEADERS, "Idempotency-Key": idempotency_key},
json={"campaign_id": campaign_id, "lead": lead},
timeout=30,
)
try:
data = response.json()
except ValueError:
data = {}
details = data.get("details") or {}
if response.status_code == 201:
return {"outcome": "created", "lead_id": data.get("lead_id")}
if response.status_code == 200:
return {"outcome": "enrolled", "lead_id": data.get("lead_id")}
error = data.get("error")
if error == "LEAD_ALREADY_IN_CAMPAIGN":
return {"outcome": "already_in_campaign"}
if error == "LEAD_SUPPRESSED":
return {"outcome": "suppressed", "detail": f"{details.get('suppressed_by')}: {details.get('value')}"}
if error == "TRIAL_LEAD_CAP_REACHED":
return {"outcome": "trial_cap_reached", "detail": f"{details.get('used')} of {details.get('cap')} leads used"}
if error == "VALIDATION_ERROR":
problems = "; ".join(f"{e['field']} {e['message']}" for e in details.get("errors", []))
return {"outcome": "invalid", "detail": problems}
return {"outcome": "failed", "detail": f"{response.status_code} {error or ''} (request_id {data.get('request_id', '?')})"}
@app.post("/events")
def receive_event():
if not hmac.compare_digest(request.headers.get("X-Event-Secret", ""), EVENT_SECRET):
return "", 401
event = request.get_json(silent=True) or {}
campaign_id = ROUTES.get(event.get("event"))
if not campaign_id:
return jsonify({"outcome": "ignored", "reason": f'no campaign for event "{event.get("event")}"'}), 200
result = enrol(campaign_id, to_lead(event))
detail = f" ({result['detail']})" if result.get("detail") else ""
print(f"{event.get('event')} {event.get('email') or event.get('linkedin_url') or '?'} → {result['outcome']}{detail}", flush=True)
if result["outcome"] == "invalid":
return jsonify(result), 422
if result["outcome"] == "failed":
return jsonify(result), 502 # let the caller retry
return jsonify(result), 202Start it with the campaign ids and the secret in the environment:
CAMPAIGN_TRIAL_STARTED=550e8400-… CAMPAIGN_HIGH_INTENT=… EVENT_SECRET=$(openssl rand -hex 32) node events.mjsThen send the event above:
curl -X POST http://localhost:3000/events \
-H "Content-Type: application/json" -H "X-Event-Secret: $EVENT_SECRET" \
-d '{"event": "trial_started", "email": "sarah.johnson@acmecorp.com", "first_name": "Sarah", "last_name": "Johnson", "company": "Acme Corp", "properties": {"plan": "Team", "signup_date": "2026-10-01"}}'What the endpoint answers
Status and outcome | Meaning |
|---|---|
202 created | A new lead was created and enrolled; lead_id is in the body. |
202 enrolled | The person was already a lead in your organization (matched by email or LinkedIn URL) and that lead was enrolled. |
202 already_in_campaign | Nothing to do; the lead is already in that campaign. |
202 suppressed | The person is on your Do Not Contact list. detail says which entry matched. Nothing was created. |
202 trial_cap_reached | The organization is on a free trial and its lead quota is used up. detail has the numbers. |
200 ignored | No campaign is mapped to that event. |
422 invalid | The API refused the lead, for example a missing last name. detail lists each problem. |
502 failed | A 429, 5xx or unexpected answer. Retry later; the Idempotency-Key makes that safe. |
401 | Wrong or missing X-Event-Secret. |
A 202 means the endpoint decided; it doesn't mean a message was sent. The campaign has to be active, and the lead moves through the sequence on the campaign's schedule like any other.
Calling it from other tools
The endpoint takes one shape, so each caller maps its own event to it:
- Your backend: the natural caller. Fire it from the code path that already knows the event happened, with the user's name and company from your own database.
- Stripe: a
customer.subscription.trial_will_endevent carries the customer id; look the customer up foremailandname, split the name, and posttrial_will_end. Verify Stripe's own signature with the Stripe SDK in the small function that does the mapping. - PostHog or Segment: both can call a webhook destination for a chosen event with the person's properties. Map
$set.email,$set.first_nameand the event's properties into the shape above, and have the destination sendX-Event-Secretas a header. - A campaign per segment: when one event should land in different campaigns by company size or plan, replace the
ROUTESlookup with a function. For example,properties.employees >= 200picks the enterprise campaign; everything else picks the default.
What to expect
- The
Idempotency-Keyis derived from the campaign, the event name and the person. A provider that redelivers an event gets the same key, and the API returns the first answer again withIdempotent-Replay: truefor at least 24 hours. After that, the lead is already in the campaign and the endpoint answersalready_in_campaign. - A person who was already a lead is enrolled as stored; the name and company in the event don't update them. The custom fields do travel with the enrolment, so
{plan}still works for them. - The same person can be in several campaigns. If
trial_startedandpricing_page_viewedboth fire, they're enrolled in both, and both campaigns will write to them. Decide which events exclude each other in your own code, or remove the lead from the earlier campaign withDELETE /v1/leads/{lead_id}?campaign_id=…. TRIAL_LEAD_CAP_REACHEDanswers403from the API and stops every further enrolment until the organization subscribes. The endpoint reports it rather than retrying.
Next steps
- Set up a campaign by API for the campaign each event enrols into, including a step that uses
{plan}. - Receive replies once and fan them out to hear what these leads say back.
- Idempotency for how keys behave across retries.