usama_kashif
How We Built a Tracked Email Funnel with PostHog + Supabase
Feb 23, 2026

How We Built a Tracked Email Funnel with PostHog + Supabase


When we were getting Ravah ready for early access, we needed to invite waitlist users and track who actually set up their account. We didn’t want a third-party drip platform — we wanted the tracking data to live next to our product data, queryable with SQL, and correlated to our PostHog funnels.

Here’s how we built it.


The Problem

“Invite sent” isn’t the end of the story — it’s the beginning of a funnel:

invite sent → delivered → clicked → password set → onboarding → campaign created

We needed visibility at every step, plus a way to automatically re-invite anyone who fell off without double-emailing them.


The Stack

  • Supabase Edge Functions (Deno) — email-sending logic
  • Resend — transactional delivery, webhooks via Svix
  • PostHog — funnel analytics, browser + server-side via posthog-node
  • PostgreSQL — source of truth for email state, with helper RPCs

Step 1: The Tracking Table

Every email we send gets a row in email_sequence_events:

CREATE TYPE email_type AS ENUM (
  'invite', 'invite_pending', 'welcome', 'checkin', 'feedback_call'
);
CREATE TYPE email_status AS ENUM ('sent', 'delivered', 'bounced', 'failed');

CREATE TABLE email_sequence_events (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  profile_id      UUID REFERENCES profiles(id) ON DELETE CASCADE,
  email_type      email_type NOT NULL,
  email_status    email_status NOT NULL DEFAULT 'sent',
  resend_email_id TEXT,   -- links back to Resend for webhook correlation
  metadata        JSONB DEFAULT '{}',
  sent_at         TIMESTAMPTZ DEFAULT NOW(),
  failed_at       TIMESTAMPTZ
);

resend_email_id is the linchpin — it’s how we match an outbound send to an inbound delivery webhook.

We wrapped mutations in three helper RPCs (record_email_event, update_email_status, has_email_been_sent) so edge functions never write raw SQL and the logic stays in one place.


Step 2: The Invite Edge Function

The batch invite sender generates a Supabase magic link, sends it via Resend, then records the event and fires a PostHog server-side event — all in one shot per user, run concurrently with Promise.all:

// for each email in the batch...
const { data: inviteData } = await supabaseAdmin.auth.admin.generateLink({
  type: "invite",
  email,
});

const { id: resendEmailId } = await fetch("https://api.resend.com/emails", {
  method: "POST",
  headers: { Authorization: `Bearer ${resendApiKey}` },
  body: JSON.stringify({
    template: {
      id: templateName,
      variables: { SUPABASE_INVITE_LINK: inviteData.properties.action_link },
    },
  }),
}).then((r) => r.json());

await supabaseAdmin.rpc("record_email_event", {
  p_profile_id: profile.id,
  p_email_type: "invite",
  p_resend_email_id: resendEmailId,
});

posthog.capture({
  distinctId: profile.id,
  event: "email_sent",
  properties: { email_type: "invite" },
});

We pass resend_api_key in the request body (not hardcoded) so we can swap sending domains — onboarding emails from one subdomain, transactional from another — without separate deployments.


Step 3: The Re-Invite Cron

A scheduled edge function finds everyone who was invited but never finished onboarding:

const { data: pending } = await supabaseAdmin
  .from("profiles")
  .select("id, email")
  .eq("onboarding_done", false)
  .eq("password_set", false);

for (const profile of pending) {
  // Skip if we already sent this email type
  const { data: sent } = await supabaseAdmin.rpc("has_email_been_sent", {
    p_profile_id: profile.id,
    p_email_type: "invite_pending",
  });
  if (sent) continue;

  // ...send, record, track, then throttle
  await new Promise((r) => setTimeout(r, 600)); // stay under Resend's 2 req/s
}

has_email_been_sent() is the whole safety mechanism — it makes this safe to run repeatedly without spamming anyone.


Step 4: Closing the Loop with Webhooks

Resend fires delivery events via Svix. Our resend-webhook edge function verifies the signature, looks up the email by resend_email_id, and updates the DB + PostHog:

// Verify Svix signature — we support multiple secrets for multiple domains
for (const secret of [RESEND_SECRET_1, RESEND_SECRET_2]) {
  try {
    new Webhook(secret).verify(rawBody, headers);
    verified = true;
    break;
  } catch {}
}

// Map Resend event → DB status + PostHog event
const map = {
  "email.delivered": ["delivered", "email_delivered"],
  "email.bounced": ["bounced", "email_bounced"],
  "email.complained": ["failed", "email_failed"],
};
const [status, phEvent] = map[payload.type];

await supabaseAdmin.rpc("update_email_status", {
  p_resend_email_id,
  p_new_status: status,
});
posthog.capture({ distinctId: emailEvent.profile_id, event: phEvent });

If the email ID isn’t in our DB, we still return HTTP 200 — always acknowledge webhooks.


Step 5: Stitching Server + Browser Events in PostHog

All server-side PostHog events use profile.id as the distinctId. The browser SDK calls posthog.identify(user.id) after login — same ID — so everything stitches into one person profile.

One gotcha: use flushAt: 1 and shutdown() in edge functions, or events get dropped when the process exits:

const ph = new PostHog(API_KEY, { flushAt: 1, flushInterval: 0 });
ph.capture({ distinctId, event, properties });
await ph.shutdown(); // flush before the function terminates

The Funnel

StepEventSource
Invite sentemail_sentEdge function
Deliveredemail_deliveredResend webhook
Onboarding startedonboarding_step_startedBrowser
Onboarding doneonboarding_completedBrowser

Drop-off between “delivered” and “onboarding started” is exactly who the re-invite cron targets. You can verify it’s working by watching those users re-enter the funnel.


What We Learned

Store the provider’s email ID. resend_email_id is what closes the loop. Without it, delivery webhooks are useless.

Build idempotency into the DB, not the caller. has_email_been_sent() as a Postgres RPC means every edge function gets it for free — no logic to duplicate.

flushAt: 1 is non-negotiable in serverless. PostHog Node batches by default. That’s exactly wrong for edge functions. We noticed this when server-side events intermittently disappeared.

Multiple webhook secrets from day one. One secret = one domain. Three secrets in a loop = full flexibility, zero cost.


The whole system took about two days to build. We haven’t had a single “did they get the email?” investigation since — everything’s either in the PostHog funnel or one SQL query away.

built by the author

turn your product into content that clicks.

ravah turns your product knowledge and weekly progress into authentic social content — no re-explaining, no templates. set up once, post every week.