usama_kashif
Email funnel architecture connecting Supabase Edge Functions, Resend webhooks, PostgreSQL, and PostHog
Feb 23, 2026
Last updated on Aug 13, 2026

How We Built a Tracked Email Funnel with PostHog + Supabase

By Usama Kashif - frontend engineer and product builder sharing practical product, UX, and engineering experience.


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.

Official references

Frequently asked questions

How do you track Resend emails in PostHog?
Store the Resend email ID with the recipient and campaign state, then verify Resend webhook events and map delivery, bounce, and complaint updates to PostHog events. Use the same stable user ID for server and browser events so the funnel joins correctly.
Why store the email provider ID in PostgreSQL?
The provider ID connects an outbound send to later webhook events. Without it, a delivery or bounce cannot be reliably matched to the correct user, email type, or campaign record.
How do you prevent a scheduled email job from sending duplicates?
Make the send operation idempotent at the database layer. Before sending, check or enforce a unique recipient-and-email-type record so retries and overlapping schedules cannot create a second send.
Why can PostHog events disappear in serverless functions?
Server SDKs often batch events, but an edge or serverless function can end before the batch is sent. Configure a small flush threshold and explicitly shut down or flush the client before returning.
built by the author / featured product

see food. snap it. cook it.

CookThis turns food photos, ingredients, and cravings into practical, beginner-friendly recipes for a real home kitchen.

explore cookthis cookthis / ai cooking