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

Works with: Stripe, PostHog, Segment.

Endpoints used:

- [`POST /v1/leads`](https://docs.versionseven.ai/api-reference/leads/create-lead) Create a lead

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

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

**Node.js (Express)**

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

**Python (Flask)**

```python
# 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), 202
```

Start it with the campaign ids and the secret in the environment:

```bash
CAMPAIGN_TRIAL_STARTED=550e8400-… CAMPAIGN_HIGH_INTENT=… EVENT_SECRET=$(openssl rand -hex 32) node events.mjs
```

Then send the event above:

```bash
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](https://docs.versionseven.ai/help/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_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

- [Set up a campaign by API](https://docs.versionseven.ai/guides/set-up-a-campaign) for the campaign each event enrols into, including a step that uses `{plan}`.
- [Receive replies once and fan them out](https://docs.versionseven.ai/cookbook/webhook-receiver) to hear what these leads say back.
- [Idempotency](https://docs.versionseven.ai/guides/idempotency) for how keys behave across retries.
