DEV Community

Syntora
Syntora

Posted on

Automating Client Onboarding with Supabase Edge Functions and Webhooks

Every time I signed a new client, I did the same 15-20 minutes of busywork. Create a record. Set up their folder structure. Provision portal access. Send a welcome email. Log everything. Five steps, every single time, all manual.

I run Syntora, a small automation consultancy. The irony of manually onboarding clients at an automation company was not lost on me. So I built a system where signing a proposal triggers all five steps automatically, with zero clicks after the signature.

This article walks through the full build: the database schema, the Edge Function that orchestrates everything, the webhook trigger, error handling for partial failures, and RLS policies for client isolation. If you run any kind of agency or SaaS and onboard clients regularly, this pattern will save you serious time.

The Architecture

The flow is simple:

  1. Client signs a proposal (hosted on an external platform).
  2. The signing platform fires a webhook to a Supabase Edge Function.
  3. The Edge Function orchestrates five steps in sequence.
  4. Every step is logged to an activity log table.
  5. If any step fails, the function logs the failure and continues with what it can.

Everything lives in Supabase: Postgres for data, Auth for portal access, Edge Functions for orchestration.

Step 1: The Database Schema

We need four tables: clients, projects, portal_users, and activity_log. Here is the full migration.

-- clients: the core record created on signature
create table public.clients (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  email text not null unique,
  company text,
  status text not null default 'active'
    check (status in ('active', 'paused', 'churned')),
  proposal_signed_at timestamptz,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

-- projects: folder/workspace structure per client
create table public.projects (
  id uuid primary key default gen_random_uuid(),
  client_id uuid not null references public.clients(id) on delete cascade,
  name text not null,
  slug text not null,
  status text not null default 'setup'
    check (status in ('setup', 'active', 'completed', 'archived')),
  config jsonb not null default '{}'::jsonb,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now(),
  unique(client_id, slug)
);

-- portal_users: links Supabase Auth users to clients
create table public.portal_users (
  id uuid primary key default gen_random_uuid(),
  auth_user_id uuid not null references auth.users(id) on delete cascade,
  client_id uuid not null references public.clients(id) on delete cascade,
  role text not null default 'viewer'
    check (role in ('owner', 'editor', 'viewer')),
  invited_at timestamptz not null default now(),
  accepted_at timestamptz,
  unique(auth_user_id, client_id)
);

-- activity_log: append-only audit trail
create table public.activity_log (
  id uuid primary key default gen_random_uuid(),
  client_id uuid references public.clients(id) on delete set null,
  action text not null,
  details jsonb not null default '{}'::jsonb,
  status text not null default 'success'
    check (status in ('success', 'failure', 'skipped')),
  error_message text,
  created_at timestamptz not null default now()
);

-- indexes for common queries
create index idx_projects_client_id on public.projects(client_id);
create index idx_portal_users_client_id on public.portal_users(client_id);
create index idx_portal_users_auth_id on public.portal_users(auth_user_id);
create index idx_activity_log_client_id on public.activity_log(client_id);
create index idx_activity_log_created_at on public.activity_log(created_at desc);
Enter fullscreen mode Exit fullscreen mode

The activity_log table is the most important piece for debugging. Every step in the onboarding flow writes a row here, whether it succeeds or fails. The details jsonb column holds whatever context is relevant: the email that was sent, the project config that was created, the error stack if something broke.

Step 2: RLS Policies for Client Isolation

Every client should only see their own data. This is non-negotiable in a multi-tenant system. Here are the RLS policies.

-- enable RLS on all tables
alter table public.clients enable row level security;
alter table public.projects enable row level security;
alter table public.portal_users enable row level security;
alter table public.activity_log enable row level security;

-- portal users can read their own client record
create policy "clients_select_own" on public.clients
  for select using (
    id in (
      select client_id from public.portal_users
      where auth_user_id = auth.uid()
    )
  );

-- portal users can read projects for their client
create policy "projects_select_own" on public.projects
  for select using (
    client_id in (
      select client_id from public.portal_users
      where auth_user_id = auth.uid()
    )
  );

-- portal users can only see their own portal_users row
create policy "portal_users_select_own" on public.portal_users
  for select using (auth_user_id = auth.uid());

-- activity log: clients see logs for their own client_id
create policy "activity_log_select_own" on public.activity_log
  for select using (
    client_id in (
      select client_id from public.portal_users
      where auth_user_id = auth.uid()
    )
  );
Enter fullscreen mode Exit fullscreen mode

Notice that every policy routes through the portal_users table. That table is the single join between a Supabase Auth user and the client they belong to. This keeps the authorization model clean: one lookup, consistent across all tables.

The Edge Function itself runs with the service_role key, so it bypasses RLS when inserting records during onboarding. RLS only applies to client-facing queries through the Supabase client library.

Step 3: The Edge Function

This is the core of the system. A single Edge Function receives the webhook, validates the payload, and runs all five onboarding steps in sequence.

import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "jsr:@supabase/supabase-js@2";

interface WebhookPayload {
  event: string;
  client_name: string;
  client_email: string;
  company: string;
  signed_at: string;
}

interface OnboardingContext {
  supabase: ReturnType<typeof createClient>;
  clientId: string | null;
  payload: WebhookPayload;
}

async function logActivity(
  ctx: OnboardingContext,
  action: string,
  status: "success" | "failure" | "skipped",
  details: Record<string, unknown> = {},
  errorMessage?: string
) {
  await ctx.supabase.from("activity_log").insert({
    client_id: ctx.clientId,
    action,
    status,
    details,
    error_message: errorMessage ?? null,
  });
}

// Step 1: Create client record
async function createClientRecord(ctx: OnboardingContext) {
  const { data, error } = await ctx.supabase
    .from("clients")
    .insert({
      name: ctx.payload.client_name,
      email: ctx.payload.client_email,
      company: ctx.payload.company,
      proposal_signed_at: ctx.payload.signed_at,
    })
    .select("id")
    .single();

  if (error) throw new Error(`Client insert failed: ${error.message}`);

  ctx.clientId = data.id;
  await logActivity(ctx, "client_created", "success", {
    email: ctx.payload.client_email,
  });

  return data.id;
}

// Step 2: Create project structure
async function createProjectStructure(ctx: OnboardingContext) {
  const slug = ctx.payload.company
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-|-$/g, "");

  const defaultProjects = [
    {
      client_id: ctx.clientId,
      name: "Primary Project",
      slug: `${slug}-primary`,
      config: { channels: ["email", "portal"], timezone: "America/Chicago" },
    },
  ];

  const { error } = await ctx.supabase
    .from("projects")
    .insert(defaultProjects);

  if (error) throw new Error(`Project setup failed: ${error.message}`);

  await logActivity(ctx, "project_created", "success", {
    projects: defaultProjects.map((p) => p.slug),
  });
}

// Step 3: Provision portal access via Supabase Auth
async function provisionPortalAccess(ctx: OnboardingContext) {
  const { data: authData, error: authError } =
    await ctx.supabase.auth.admin.inviteUserByEmail(
      ctx.payload.client_email,
      {
        data: {
          client_id: ctx.clientId,
          role: "owner",
          invited_by: "onboarding_automation",
        },
        redirectTo: `${Deno.env.get("PORTAL_URL")}/welcome`,
      }
    );

  if (authError)
    throw new Error(`Auth invite failed: ${authError.message}`);

  const { error: portalError } = await ctx.supabase
    .from("portal_users")
    .insert({
      auth_user_id: authData.user.id,
      client_id: ctx.clientId,
      role: "owner",
    });

  if (portalError)
    throw new Error(`Portal user insert failed: ${portalError.message}`);

  await logActivity(ctx, "portal_access_provisioned", "success", {
    auth_user_id: authData.user.id,
  });
}

// Step 4: Send welcome email
async function sendWelcomeEmail(ctx: OnboardingContext) {
  const response = await fetch("https://api.resend.com/emails", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${Deno.env.get("RESEND_API_KEY")}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      from: "Parker <parker@syntora.io>",
      to: ctx.payload.client_email,
      subject: `Welcome to Syntora, ${ctx.payload.client_name}`,
      html: `
        <p>Hi ${ctx.payload.client_name},</p>
        <p>Thanks for signing with Syntora. Your portal access is being set up now.
        You will receive a separate email with your login link shortly.</p>
        <p>In the meantime, here is what happens next:</p>
        <ul>
          <li>Your project workspace is ready</li>
          <li>Portal access is provisioned (check your email for the invite)</li>
          <li>I will reach out within 24 hours to schedule our kickoff</li>
        </ul>
        <p>Looking forward to working together.</p>
        <p>Parker Gawne<br/>Syntora</p>
      `,
    }),
  });

  if (!response.ok) {
    const body = await response.text();
    throw new Error(`Email send failed: ${body}`);
  }

  await logActivity(ctx, "welcome_email_sent", "success", {
    to: ctx.payload.client_email,
  });
}

// Step 5: Final log entry
async function logOnboardingComplete(ctx: OnboardingContext) {
  await logActivity(ctx, "onboarding_complete", "success", {
    client_id: ctx.clientId,
    email: ctx.payload.client_email,
    company: ctx.payload.company,
  });
}

Deno.serve(async (req: Request) => {
  // Validate webhook secret
  const webhookSecret = req.headers.get("x-webhook-secret");
  if (webhookSecret !== Deno.env.get("ONBOARDING_WEBHOOK_SECRET")) {
    return new Response(JSON.stringify({ error: "Unauthorized" }), {
      status: 401,
    });
  }

  const payload: WebhookPayload = await req.json();

  if (payload.event !== "proposal.signed") {
    return new Response(JSON.stringify({ status: "ignored" }), {
      status: 200,
    });
  }

  const supabase = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
  );

  const ctx: OnboardingContext = {
    supabase,
    clientId: null,
    payload,
  };

  const steps = [
    { name: "create_client", fn: createClientRecord, critical: true },
    { name: "create_project", fn: createProjectStructure, critical: false },
    { name: "provision_portal", fn: provisionPortalAccess, critical: false },
    { name: "send_welcome_email", fn: sendWelcomeEmail, critical: false },
    { name: "log_completion", fn: logOnboardingComplete, critical: false },
  ];

  const results: Record<string, string> = {};

  for (const step of steps) {
    try {
      await step.fn(ctx);
      results[step.name] = "success";
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      results[step.name] = "failure";

      await logActivity(ctx, step.name, "failure", {}, message);

      // If a critical step fails, abort the rest
      if (step.critical) {
        return new Response(
          JSON.stringify({
            status: "failed",
            failed_step: step.name,
            error: message,
            results,
          }),
          { status: 500 }
        );
      }
    }
  }

  return new Response(JSON.stringify({ status: "completed", results }), {
    status: 200,
    headers: { "Content-Type": "application/json" },
  });
});
Enter fullscreen mode Exit fullscreen mode

Handling Partial Failures

The most important design decision in this function is the critical flag on each step. Step 1 (creating the client record) is critical. If it fails, nothing else can proceed because every subsequent step depends on the client_id. But steps 2 through 5 are independent enough to continue if one fails.

If step 3 (portal access) throws an error but steps 1 and 2 succeeded, the function logs the failure and keeps going. The welcome email still sends. The completion log still writes. I get a failure record in the activity log, and I can manually fix the portal access later.

This is better than the alternative: wrapping everything in a single transaction that rolls back entirely. In onboarding workflows, a partial success is almost always better than a full rollback. The client record exists, the project is set up, and I just need to re-run the failed step.

Step 4: The Webhook Trigger

The webhook configuration depends on your proposal platform. Most tools (PandaDoc, DocuSign, Proposify, HoneyBook) support webhook callbacks on signature events. The setup is the same across all of them:

  1. Set the webhook URL to your Edge Function endpoint:
    https://<project-ref>.supabase.co/functions/v1/onboard-client

  2. Set a shared secret header (x-webhook-secret) that your function validates.

  3. Map the payload fields to match the WebhookPayload interface.

If your proposal tool sends a different payload shape, add a normalization layer at the top of the Edge Function that maps their fields to yours. Keep the core orchestration code clean.

To deploy the function with the Supabase CLI:

supabase functions deploy onboard-client
Enter fullscreen mode Exit fullscreen mode

Then set your secrets:

supabase secrets set ONBOARDING_WEBHOOK_SECRET=your-secret-here
supabase secrets set RESEND_API_KEY=re_your-key-here
supabase secrets set PORTAL_URL=https://portal.yourdomain.com
Enter fullscreen mode Exit fullscreen mode

Step 5: Querying the Activity Log

Once this is running, the activity log becomes your single source of truth for onboarding status. Here are a few useful queries.

Check the full onboarding history for a client:

select action, status, details, error_message, created_at
from activity_log
where client_id = '<client-uuid>'
order by created_at asc;
Enter fullscreen mode Exit fullscreen mode

Find all failed steps across all clients (useful for a daily check):

select
  c.name as client_name,
  al.action,
  al.error_message,
  al.created_at
from activity_log al
join clients c on c.id = al.client_id
where al.status = 'failure'
order by al.created_at desc;
Enter fullscreen mode Exit fullscreen mode

Get a count of onboardings by status over the last 30 days:

select
  status,
  count(*) as total
from activity_log
where action = 'onboarding_complete'
  and created_at > now() - interval '30 days'
group by status;
Enter fullscreen mode Exit fullscreen mode

What I Learned

Log everything, even successes. The activity log is not just for debugging failures. It is a full audit trail. When a client asks "when was my account set up?" you have the answer in one query.

Partial success beats full rollback. For workflow orchestration (as opposed to financial transactions), continuing through non-critical failures and fixing them later is almost always the right call. Your activity log tells you exactly what needs manual attention.

RLS through a single join table. Routing all authorization through portal_users keeps the policy definitions simple and consistent. Adding a new table to the system means writing one policy that joins to portal_users, and you are done.

Edge Functions are the right fit for webhooks. They cold-start fast, run close to your database, and share the same auth infrastructure. For orchestration tasks like this, where you are calling your own database and a couple external APIs, they are the simplest option.

The full onboarding now takes about 3 seconds from proposal signature to completion. No clicking, no copy-pasting, no forgetting steps. The activity log catches anything that goes wrong, and I fix it once instead of discovering it three days later when a client asks why they cannot log in.


I'm Parker Gawne, founder of Syntora. We build custom Python infrastructure for small and mid-size businesses. syntora.io

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.