Trigger email campaigns from Revenue Moments

Wire trial and conversion webhooks into your email platform so nurture starts the minute a moment happens in an agent thread.

Trigger email campaigns from Revenue Moments

When a user hits a trial gate or a usage wall inside an agent thread, that moment is invisible to your email platform. The user may close the thread, the task moves on, and your next scheduled touch is whatever your ESP had planned — days later, unrelated to what just happened.

This recipe fixes that. You subscribe to trial and conversion webhooks, resolve each event to a user in your receiver, and hand it to your email platform as a campaign trigger. The result: the welcome email goes out the minute a trial starts, the conversion nurture starts the minute a trial is expiring, and everything is suppressed the minute the user converts — whether or not they ever return to the thread.

What you need before starting:

  • A webhook subscription — see Webhooks for creation, signing, and delivery semantics.
  • An email platform that can trigger campaigns from an API event or inbound webhook (most can).
  • The subject_ref mapping below.

Prerequisite: resolve subject_ref to your user

Webhook payloads identify users by subject_ref — a pseudonymous reference your own SDK integration mints via getSubjectRef. No email addresses or names ever appear in a payload, so your receiver has to translate the ref back to a user before it can talk to an email platform.

The standard pattern: persist the ref-to-user mapping on your side, keyed exactly the way your getSubjectRef produces refs.

import { createHmac } from "node:crypto";

// Mirror of the getSubjectRef you configured in the SDK —
// same key, same derivation, same output.
function subjectRefFor(userId: string): string {
  const digest = createHmac("sha256", process.env.SUBJECT_REF_KEY!)
    .update(userId)
    .digest("hex");
  return `u_${digest.slice(0, 16)}`;
}

// At signup (and once as a backfill), persist the mapping:
await db.subjectRefs.upsert({ ref: subjectRefFor(user.id), userId: user.id });

// In your webhook receiver:
const mapping = await db.subjectRefs.findByRef(event.data.subject_ref);
const user = mapping ? await db.users.find(mapping.userId) : null;

If your getSubjectRef is deterministic (an HMAC of your user id, as above), you can also skip the lookup table and recompute refs on demand — the table just makes reverse lookup a single indexed query.

Path A: webhook → your email platform

The general shape is a small receiver that verifies, dedupes, resolves, and relays:

app.post(
  "/webhooks/inception",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const raw = req.body.toString("utf-8");
    if (!verifySignature(raw, req.headers["x-inception-signature"], SECRET)) {
      return res.status(401).end();
    }
    res.status(200).json({ received: true }); // acknowledge fast

    const event = JSON.parse(raw);
    if (await alreadyProcessed(event.webhookId)) return; // at-least-once: dedupe
    const user = await resolveSubjectRef(event.data.subject_ref);
    if (!user) return;

    await relayToEmailPlatform(event, user); // one of the relays below
  }
);

(verifySignature is the snippet from Signature verification.)

The relay step depends on the platform. Three common generic patterns — these are plain HTTP calls to each platform’s public API from your own receiver:

Customer.io — event-triggered campaigns

Send the event to the track API, then build a campaign triggered by that event name:

async function relayToEmailPlatform(event, user) {
  await fetch(`https://track.customer.io/api/v1/customers/${user.id}/events`, {
    method: "POST",
    headers: {
      Authorization: "Basic " + btoa(`${SITE_ID}:${TRACK_API_KEY}`),
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      name: event.event, // e.g. "trial.expiring"
      data: {
        wall_key: event.data.moment?.wall_key ?? "none",       // routing input
        likelihood: event.data.enrichment?.conversion_likelihood ?? "unknown",
        utm: event.data.attribution.params,
      },
    }),
  });
}

Klaviyo — events API relay

Post a metric event against the profile; flows subscribe to the metric:

async function relayToEmailPlatform(event, user) {
  await fetch("https://a.klaviyo.com/api/events/", {
    method: "POST",
    headers: {
      Authorization: `Klaviyo-API-Key ${KLAVIYO_PRIVATE_KEY}`,
      "Content-Type": "application/json",
      revision: "2024-10-15",
    },
    body: JSON.stringify({
      data: {
        type: "event",
        attributes: {
          metric: { data: { type: "metric", attributes: { name: event.event } } },
          profile: { data: { type: "profile", attributes: { email: user.email } } },
          properties: {
            wall_key: event.data.moment?.wall_key ?? "none",
            utm: event.data.attribution.params,
          },
        },
      },
    }),
  });
}

HubSpot — workflow webhook trigger

Create a workflow with a webhook trigger and forward a flattened payload to its trigger URL:

async function relayToEmailPlatform(event, user) {
  await fetch(HUBSPOT_WORKFLOW_TRIGGER_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      email: user.email,
      event: event.event,
      wall_key: event.data.moment?.wall_key ?? "none",
      likelihood: event.data.enrichment?.conversion_likelihood ?? "unknown",
      utm: event.data.attribution.params,
    }),
  });
}

In all three cases, note what gets forwarded: the event name, routing fields (wall_key, conversion_likelihood), and the attribution string. These select which sequence a user enters — they are never merged into email copy. See the routing rule.

Path B: Zapier or n8n

If you would rather not run a receiver, point the subscription at an automation platform:

  1. Catch the webhook. Zapier: a Catch Hook trigger; n8n: a Webhook node. Use the generated URL as the subscription url. In n8n you can verify the X-Inception-Signature header in a Code node; in Zapier, treat the hook URL itself as a secret and restrict the Zap to the event types you subscribed to.
  2. Find the contact. The payload has no email address, so store each user’s subject_ref as a custom property on their ESP/CRM contact at signup. The “find contact” step then searches by that property — no resolver service needed.
  3. Add to the journey. Enroll the contact in the sequence for the event (and route by wall_key where you have per-wall sequences).

Path C: agent-driven setup

The whole flow above can be set up conversationally. Connect the Inception MCP server (mcp.inceptionagents.com) and your email platform’s MCP server in the same agent session, then run the moment-campaigns guided prompt — it walks the full setup: choosing event types, creating the subscription (create_webhook_subscription), sending a test (send_test_webhook), checking the result (list_webhook_deliveries), and drafting the sequence itself (draft_campaign_brief, with get_trial_pipeline and export_moment_audience for sizing and segments).

The moment-campaigns prompt ships shortly after this doc; the underlying tools are available now, so an agent can already do each step on request.

The trial-to-paid sequence

The flagship recipe is three emails mapped to the trial lifecycle, with hard suppression on conversion:

EmailTrigger eventContent
1. Welcome + activationtrial.startedGet the user to your activation milestone. Link the two or three actions that predict conversion.
2. Conversion nurturetrial.expiringStart the end-of-trial sequence. Route by the wall family the subject hit during the trial (from earlier moment.wall_hit events, or the wall_key on this event): a subject who hit a seat cap enters the seats sequence, one who hit an export limit enters the exports sequence. Each sequence’s copy is written for its wall family — the field picks the sequence; it never appears in the prose.
3. Win-backtrial.expiredAfter a short grace window (a day or two), a single win-back with a clear re-entry path.

Suppression is not optional. On conversion.trial_converted or conversion.upgrade_accepted, remove the subject from every sequence in this table immediately — in most ESPs this is a campaign exit condition or suppression event keyed on those two event names. Nothing erodes trust faster than a “your trial is ending” email arriving after the user already paid, in the thread, minutes ago.

Two optional refinements:

  • Use trial.activated to switch email 1’s follow-ups from activation tips to conversion messaging early.
  • Use value.delivered as a send-time gate — asks land better right after the product did something useful.

Close the loop with attribution

Every payload carries data.attribution.params — a ready-made query string like:

utm_source=inception_moment&utm_campaign=trial.expiring

Forward it to your ESP as an event property (the relays above pass it as utm) and append it to the CTA links in the campaign emails:

https://app.example.com/upgrade?{{ event.utm }}

When the user clicks through, the arrival is tied back to the originating moment in Inception reporting — so you can see which events and sequences actually produce conversions, not just sends and opens.

Test before you enable

Run the whole path against a disabled subscription before any real event flows:

  1. Create the subscription with the trial and conversion event types (see Managing subscriptions).
  2. Disable it while you wire things up: PATCH { "active": false }.
  3. Test send. POST /api/v1/webhooks/subscriptions/:id/test with each event type you care about — the tester works while the subscription is disabled and sends a clearly synthetic payload (subject_test_0000), signed with your real secret.
  4. Verify. Check the response (delivered, statusCode, responseTimeMs), the deliveries log, and — most importantly — that your ESP shows the test event arriving and would route it to the right sequence. Confirm your receiver treats the synthetic subject as unresolvable and drops it cleanly.
  5. Enable. PATCH { "active": true }, and the next real moment starts the sequence.