Cookbook
View as Markdown

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

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:write in VICTORIA_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 as X-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 with flask and requests.

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}`));

Start 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.mjs

Then 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 outcomeMeaning
202 createdA new lead was created and enrolled; lead_id is in the body.
202 enrolledThe person was already a lead in your organization (matched by email or LinkedIn URL) and that lead was enrolled.
202 already_in_campaignNothing to do; the lead is already in that campaign.
202 suppressedThe person is on your Do Not Contact list. detail says which entry matched. Nothing was created.
202 trial_cap_reachedThe organization is on a free trial and its lead quota is used up. detail has the numbers.
200 ignoredNo campaign is mapped to that event.
422 invalidThe API refused the lead, for example a missing last name. detail lists each problem.
502 failedA 429, 5xx or unexpected answer. Retry later; the Idempotency-Key makes that safe.
401Wrong 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_end event carries the customer id; look the customer up for email and name, split the name, and post trial_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_name and the event's properties into the shape above, and have the destination send X-Event-Secret as a header.
  • A campaign per segment: when one event should land in different campaigns by company size or plan, replace the ROUTES lookup with a function. For example, properties.employees >= 200 picks the enterprise campaign; everything else picks the default.

What to expect

  • The Idempotency-Key is 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 with Idempotent-Replay: true for at least 24 hours. After that, the lead is already in the campaign and the endpoint answers already_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_started and pricing_page_viewed both 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 with DELETE /v1/leads/{lead_id}?campaign_id=….
  • TRIAL_LEAD_CAP_REACHED answers 403 from the API and stops every further enrolment until the organization subscribes. The endpoint reports it rather than retrying.

Next steps