Pre-Black Friday: 20% off, for as long as you stayEARLYBFon Solo and AgencyEnds Nov 28Start free

How-to guide

How to Enrich a Supabase Table With LeadOcean (Edge Function + pg_cron)

A scheduled job picks up new rows, calls LeadOcean enrich, and writes the result back to the same row. One record per matched row.

Get your free API key →Free to start. No credit card. 1,000 records to spend whenever you like.

You enrich a Supabase table by running a small Edge Function on a schedule. It reads rows with no enrichment yet, calls LeadOcean once per row, and updates the row with the email, phone and profile it gets back.

Supabase documents scheduling Edge Functions with pg_cron and pg_net, September 2026. This guide uses that pattern.

TL;DR

  • It takes about 30 minutes: 10 for the table and function, 10 to deploy, 10 to test on a small batch.
  • It spends 1 record per matched row. A batch of 50 costs up to 50 of your 1,000 free records. A row we hold nothing for is a 404 and costs 0.
  • The job stores linkedin_url or email as the lookup key. There is no name plus domain lookup, so keep one of those on every row.

Prerequisites

  • A Supabase project with the pg_cron, pg_net and vault extensions available, and the Supabase CLI installed.
  • A LeadOcean API key from app.leadocean.io. The free plan has no card and 1,000 records, one-off.
  • The key as $LEADOCEAN_API_KEY in your shell. It never goes in a table or in Git.
bash
export LEADOCEAN_API_KEY="paste-your-key-here"

Step 1: Add the columns the job writes to

Keep the raw answer in jsonb and pull out the fields you query. Rows start with enriched_at empty, which is how the job finds its work.

sql
create table if not exists public.leads (
  id            bigint generated always as identity primary key,
  linkedin_url  text,
  email         text,
  enriched_at   timestamptz,
  enrich_status text,          -- matched | not_found | error
  work_email    text,
  email_status  text,
  phone         text,
  profile       jsonb
);

create table if not exists public.companies (
  id          bigint generated always as identity primary key,
  domain      text not null,
  enriched_at timestamptz,
  company     jsonb
);

insert into public.leads (linkedin_url)
values ('https://www.linkedin.com/in/janedoe-example');
insert into public.companies (domain) values ('acme.com');

Both rows use placeholders. Swap in your own data.

Step 2: Write the Edge Function

The function takes up to 50 pending rows, calls POST /v1/people/enrich with reveal_email true, and writes the result back. It sends one request at a time, which stays under the 5 requests a second on Free.

ts
// supabase/functions/enrich-leads/index.ts
import { createClient } from "npm:@supabase/supabase-js@2";

const db = createClient(
  Deno.env.get("SUPABASE_URL")!,
  Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);
const KEY = Deno.env.get("LEADOCEAN_API_KEY")!;
const API = "https://api.leadocean.io";

async function call(path: string, body: Record<string, unknown>) {
  const r = await fetch(API + path, {
    method: "POST",
    headers: { "x-api-key": KEY, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (r.status === 404) return { notFound: true };
  if (r.status === 429 || r.status === 402 || r.status === 401) {
    throw new Error(`stop:${r.status}`);
  }
  return r.json();
}

Deno.serve(async () => {
  const now = new Date().toISOString();
  let people = 0, companies = 0;

  const { data: leads } = await db.from("leads").select("*")
    .is("enriched_at", null).limit(50);
  for (const row of leads ?? []) {
    const key = row.linkedin_url
      ? { linkedin_url: row.linkedin_url } : { email: row.email };
    const res = await call("/v1/people/enrich", { ...key, reveal_email: true });
    if (res.notFound) {
      await db.from("leads").update({ enriched_at: now, enrich_status: "not_found" })
        .eq("id", row.id);
      continue;
    }
    const c = res.data?.contact_data ?? {};
    await db.from("leads").update({
      enriched_at: now, enrich_status: "matched",
      work_email: c.contact_emails?.[0]?.email ?? null,
      email_status: c.email_status ?? null,
      phone: c.contact_phones?.[0]?.phone ?? null,
      profile: res.data?.profile_data ?? null,
    }).eq("id", row.id);
    people++;
  }

  const { data: cos } = await db.from("companies").select("*")
    .is("enriched_at", null).limit(50);
  for (const row of cos ?? []) {
    const res = await call("/v1/companies/enrich", { domain: row.domain });
    await db.from("companies").update({
      enriched_at: now, company: res.notFound ? null : res.data,
    }).eq("id", row.id);
    if (!res.notFound) companies++;
  }
  return Response.json({ people, companies });
});

A 401, 402 or 429 throws and ends the run, so a bad key or spent records never loops. Rows left untouched are picked up on the next run.

Step 3: Store the key and deploy

Supabase reads function secrets from its secrets store, September 2026. Set the key there, not in code.

bash
supabase secrets set LEADOCEAN_API_KEY="$LEADOCEAN_API_KEY"
supabase functions deploy enrich-leads

Check the function runs by hand before you schedule it. Insert two or three test rows first.

bash
curl -X POST "https://<project-ref>.supabase.co/functions/v1/people/enrich-leads" \
  -H "Authorization: Bearer <SUPABASE_PUBLISHABLE_KEY>"

The reply is a count such as {"people":1,"companies":1}.

Step 4: Schedule it with pg_cron

Put the project URL and publishable key in Vault, then schedule a call every hour. This follows the pattern in the Supabase scheduling guide.

sql
select vault.create_secret('https://<project-ref>.supabase.co', 'project_url');
select vault.create_secret('<SUPABASE_PUBLISHABLE_KEY>', 'publishable_key');

select cron.schedule(
  'enrich-leads-hourly',
  '0 * * * *',
  $$
  select net.http_post(
    url := (select decrypted_secret from vault.decrypted_secrets
            where name = 'project_url') || '/functions/v1/people/enrich-leads',
    headers := jsonb_build_object(
      'Content-type', 'application/json',
      'apikey', (select decrypted_secret from vault.decrypted_secrets
                 where name = 'publishable_key')
    ),
    body := '{}'::jsonb
  ) as request_id;
  $$
);

Fifty rows an hour is 1,200 rows a day. Raise the limit in the function for a larger backlog.

Before you leave it running, make three small changes.

  • Add an updated_at check or a retry column so rows with a transient error are retried later and not marked not_found.
  • Keep the service role key out of the browser. The function runs server side, so the key stays in Supabase.
  • Store fetched_at from the response if you plan to refresh rows. The dataset is refreshed monthly, so re-enriching the same row weekly gains nothing.

Step 5: Read the results

Query the table. Treat only some email_status values as safe to mail.

sql
select id, work_email, email_status, phone, enrich_status
from public.leads
where enrich_status = 'matched'
  and email_status in ('verified', 'catch_all_valid', 'catch_all');

Drop invalid, spam_trap, abuse and disposable. LeadOcean gives no refund for bounced emails, so filter before you send.

One record is one person or one company you receive. A people enrich that matches is 1 record, and a company enrich that matches is 1 record. A 404, a count and an empty page cost nothing.

On Free, the 1,000 records are one-off, so the hourly job stops with a 402 once they are spent. On a paid plan there are no credits. Solo is $97 a month and Agency is $497 a month, each with a fair-use pace instead of a balance. Reaching the pace slows the key until the window resets, and nothing stops. Bulk CSV export is not on Free.

What you get

The email_status list the function relies on comes from leadocean_list_enum_values, a free call. We ran it on 2026-10-21.

json
{"name":"email_status","total":13,"matched":13,"values":["verified","catch_all_valid","catch_all","risky","unknown","untested","invalid","role","disposable","spam_trap","abuse","derived","none"],"truncated":false}

Each matched person returns this shape. It is the response shape from the API reference, with placeholder values.

json
{
  "success": true,
  "data": {
    "profile_data": { "...": "name, title, company" },
    "contact_data": {
      "has_email": true,
      "email_status": "verified",
      "contact_emails": [{ "email": "jane.doe@acme.com", "type": "work" }],
      "contact_phones": [{ "phone": "+14155550133", "type": "mobile" }]
    }
  }
}

Person enrich also returns career history in resume_data when held: past roles, education and certifications. The job above does not store it. Add it to profile if you want it.

Troubleshooting

ErrorCauseFix
401The x-api-key header is missing or the key is wrong.Re-run supabase secrets set with a fresh key and redeploy.
402Your records are spent. Free is 1,000, one-off.Check GET /v1/account, or compare plans on pricing.
404We hold nothing for that identifier.Nothing to fix. It costs 0, and the job marks the row not_found.
429Requests came faster than the plan's rate limit: 5 a second on Free, 200 on Agency.Wait the Retry-After seconds. The one-at-a-time loop stays under it.
Rows never updateThe cron job ran but the function failed, or enriched_at is already set.Check select * from cron.job_run_details and the function logs.
Empty search resultsA search filter used a value that is not an exact enum string.Call leadocean_list_enum_values for the exact string.

FAQ

Can I enrich by name and company domain?

Not directly. Enrich takes exactly one of linkedin_url, email, phone or person_id. Search by domain first, then enrich by person_id.

Will the job re-spend records on rows it already enriched?

No. It only selects rows where enriched_at is null, and it sets that column on a match and on a 404.

How do I size a company list before I spend anything?

Use GET /v1/companies/count or the leadocean_count_companies MCP tool. Both are free and return an exact total.

Can I use a database trigger instead of a schedule?

Yes, with pg_net. A schedule is simpler to cap, because each run handles a fixed batch.

Do other warehouses work the same way?

The call is identical. See Postgres, Snowflake and Databricks. More recipes are in the blog hub.

Enrich your Supabase rows on a schedule, free

Free to start. No credit card. 1,000 records to spend whenever you like.

Get your free API key →