Skip to main content

Command Palette

Search for a command to run...

Test Supabase Edge Functions, Webhooks and Cron Locally Without Docker

Run the async half of your Supabase backend on your laptop, with real Postgres and no containers

Updated
•10 min read•View as Markdown

Most Supabase apps have two halves. The synchronous half is easy to test: the client calls select or insert, you look at the result. The asynchronous half is where things go quiet: a database webhook fires an Edge Function when a row lands, a cron job wakes up at 7am and posts to another function, and the function writes back into the database with the service role key.

That second half is usually tested in staging, or in production, because running it locally means supabase start, a dozen containers and the edge runtime image. On a laptop that is already running a simulator and a browser, that is a lot to ask for "did my webhook payload parse?"

This post walks through a small order pipeline and runs all of it locally in one Node process: a webhook that calls an Edge Function on every new order, a function that writes back with the service role, and a cron job that calls a second function through pg_net. Along the way it covers three gotchas that came up while building it, which will save you some confused debugging.

What we're building

A shop has an orders table. Two things should happen without the client doing anything:

Trigger Edge Function What it does
Database webhook on INSERT into orders order-created Logs a confirmation event, or flags orders over $500 for review
Cron job every morning daily-digest Totals the last 24 hours of orders into a digest event

Both functions write into an order_events table that clients cannot read. Everything below uses standard Supabase project layout, so the same files deploy to hosted Supabase.

The local stack: one process instead of a container set

The backend here is Tinbase, an MIT-licensed, Supabase-compatible backend that runs as a single process with real Postgres underneath. For this post the relevant parts are:

  • Edge Functions loaded from supabase/functions/<name>/index.ts, with Deno.serve() handlers and Deno.env supported through a shim, so Supabase-style functions run under Node.

  • Database webhooks configured in supabase/webhooks.json, posting Supabase's exact payload shape (type, table, schema, record, old_record).

  • cron.schedule and net.http_post emulated in-process, so the usual "cron calls an Edge Function" SQL runs without the pg_cron or pg_net extensions.

  • Vault emulated, so secrets can be read in SQL the way Supabase's docs recommend.

One honest caveat up front: Tinbase is in alpha (v0.19.0 when this was written). Use it for local development and tests, not as your production backend.

Step 1: Project setup

You need Node 22 or newer.

mkdir order-pipeline && cd order-pipeline
npm init -y
npm pkg set type=module
npm i -D tinbase @supabase/supabase-js

esbuild comes in as an optional dependency of Tinbase. Keep it: it is what bundles your TypeScript functions and resolves npm: imports. Without it, only plain-JS functions with no remote imports will load.

The layout follows Supabase CLI conventions:

supabase/
├── migrations/
│   ├── 20261008000000_orders.sql
│   └── 20261008000100_digest_cron.sql
├── functions/
│   ├── .env
│   ├── order-created/index.ts
│   └── daily-digest/index.ts
├── webhooks.json
└── seed.sql

Step 2: The schema

-- supabase/migrations/20261008000000_orders.sql
create table public.orders (
  id bigint generated always as identity primary key,
  customer_email text not null,
  total_cents int not null check (total_cents > 0),
  created_at timestamptz not null default now()
);

create table public.order_events (
  id bigint generated always as identity primary key,
  order_id bigint references public.orders (id) on delete cascade,
  kind text not null,
  detail jsonb,
  created_at timestamptz not null default now()
);

alter table public.orders enable row level security;
alter table public.order_events enable row level security;

create policy "anyone can place an order"
  on public.orders for insert to anon, authenticated
  with check (true);

order_events has RLS enabled and no policies, so only the service role can touch it. That is the point: the functions write there, clients never see it.

Step 3: The webhook function

// supabase/functions/order-created/index.ts
import { createClient } from 'npm:@supabase/supabase-js@2'

type OrderRow = { id: number; customer_email: string; total_cents: number }
type WebhookPayload = {
  type: 'INSERT' | 'UPDATE' | 'DELETE'
  table: string
  schema: string
  record: OrderRow | null
  old_record: OrderRow | null
}

Deno.serve(async (req) => {
  const admin = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
  )

  if (req.headers.get('x-webhook-secret') !== Deno.env.get('WEBHOOK_SECRET')) {
    return new Response('unauthorized', { status: 401 })
  }

  const payload: WebhookPayload = await req.json()
  if (payload.type !== 'INSERT' || !payload.record) {
    return new Response('ignored', { status: 200 })
  }

  const order = payload.record
  const flagged = order.total_cents >= 50_000

  const { error } = await admin.from('order_events').insert({
    order_id: order.id,
    kind: flagged ? 'flagged_for_review' : 'confirmation_queued',
    detail: { email: order.customer_email, total_cents: order.total_cents },
  })
  if (error) return new Response(error.message, { status: 500 })

  return Response.json({ ok: true, flagged })
})

The shared secret goes in supabase/functions/.env, the same file supabase functions serve --env-file would use:

# supabase/functions/.env
WEBHOOK_SECRET=local-dev-secret

SUPABASE_URL, SUPABASE_ANON_KEY and SUPABASE_SERVICE_ROLE_KEY are injected automatically, as they are on hosted Supabase.

Gotcha 1: create the client inside the handler

The first version of this function created admin at the top of the file, outside Deno.serve. It failed to load with supabaseUrl is required.

The reason: Tinbase binds Deno.env to the backend's function environment per invocation, so environment values are available inside the handler but not while the module is first imported. Creating the client inside the handler fixes it, and costs nothing meaningful since createClient is cheap. It runs the same way on hosted Supabase, so the code stays portable.

Step 4: Wire the webhook

supabase/webhooks.json:

[
  {
    "table": "orders",
    "events": ["INSERT"],
    "url": "http://127.0.0.1:54321/functions/v1/order-created",
    "headers": {
      "Authorization": "Bearer <anon key printed by tinbase start>",
      "x-webhook-secret": "local-dev-secret"
    }
  }
]

Gotcha 2: the function endpoint wants a key

The first run without the Authorization header got a 401 before the function code ever ran. /functions/v1 checks for a valid API key or JWT, which mirrors hosted Supabase, where functions verify the JWT by default. The local anon key is the well-known Supabase demo key and is identical on every machine, so it is safe to commit here. In production, configure the Database Webhook in the dashboard with your real project's key.

Now start the backend:

npx tinbase start

The startup banner should list both functions and the webhook:

  applied migrations: 20261008000000_orders, ...
         Functions: daily-digest, order-created
          Webhooks: orders

If a function is missing from that list, the warning just above the banner tells you why.

Step 5: Fire it

A short script that acts like the storefront (anon key) and then checks the result as an admin:

// scripts/place-orders.ts
import { createClient } from '@supabase/supabase-js'

const url = 'http://127.0.0.1:54321'
const shop = createClient(url, process.env.SUPABASE_ANON_KEY!)
const admin = createClient(url, process.env.SUPABASE_SERVICE_ROLE_KEY!)

await shop.from('orders').insert([
  { customer_email: 'ana@example.com', total_cents: 4200 },
  { customer_email: 'big@example.com', total_cents: 72000 },
])

await new Promise((r) => setTimeout(r, 1500)) // webhooks are async

const { data } = await admin.from('order_events').select('order_id, kind').order('id')
console.log(data)

const { data: leaked } = await shop.from('order_events').select('*')
console.log('visible to anon:', leaked)

Run it with the keys from the .env.local that tinbase start prints (Node 22 runs the TypeScript directly):

node --env-file=.env.local scripts/place-orders.ts

Output from a real run:

[
  { order_id: 1, kind: 'confirmation_queued' },
  { order_id: 2, kind: 'flagged_for_review' }
]
visible to anon: []

The insert went through PostgREST, the change was captured, the webhook posted Supabase's payload to the function, and the function wrote back with the service role. The anon client sees nothing in order_events, so RLS held.

Step 6: The cron job

This is the standard Supabase pattern from the Cron docs: read the project URL and key from Vault, then net.http_post to the function.

-- supabase/migrations/20261008000100_digest_cron.sql
select cron.schedule(
  'daily-digest',
  '0 7 * * *',
  $$
  select net.http_post(
    url := (select decrypted_secret from vault.decrypted_secrets where name = 'project_url')
           || '/functions/v1/daily-digest',
    headers := jsonb_build_object(
      'Content-Type', 'application/json',
      'Authorization', 'Bearer ' || (select decrypted_secret from vault.decrypted_secrets where name = 'anon_key')
    ),
    body := '{}'::jsonb
  );
  $$
);
-- supabase/seed.sql (local values only)
select vault.create_secret('http://127.0.0.1:54321', 'project_url');
select vault.create_secret('<local anon key>', 'anon_key');

And the function:

// supabase/functions/daily-digest/index.ts
import { createClient } from 'npm:@supabase/supabase-js@2'

Deno.serve(async () => {
  const admin = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
  )

  const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
  const { data: orders, error } = await admin
    .from('orders')
    .select('total_cents')
    .gte('created_at', since)
  if (error) return new Response(error.message, { status: 500 })

  const revenue = orders.reduce((sum, o) => sum + o.total_cents, 0)
  await admin.from('order_events').insert({
    kind: 'daily_digest',
    detail: { orders: orders.length, revenue_cents: revenue },
  })

  return Response.json({ orders: orders.length, revenue_cents: revenue })
})

To watch it run without waiting until 7am, temporarily change the schedule to '30 seconds' (the seconds form is supported, like newer pg_cron). Schedules are matched in UTC, as on hosted Supabase.

Gotcha 3: local pg_net will not call localhost

With a 30-second schedule, the cron job ran and reported succeeded, but the digest never appeared. The answer was in the pg_net response table:

select status_code, error_msg from net._http_response order by id desc limit 1;
 status_code | error_msg
-------------+--------------------------
             | blocked host: 127.0.0.1

Tinbase's pg_net refuses loopback and private-network targets as an SSRF guard, and in v0.19.0 there is no switch to relax it. Database webhooks are different: they allow local targets by default, which is why Step 5 worked.

So locally, the cron path splits into two checks:

  1. The schedule and the SQL work. cron.job_run_details shows succeeded and net._http_response shows the request was built with the right URL and headers from Vault. That is the part that usually breaks in migrations.

  2. The function works. Invoke it directly, exactly as the cron job would:

const { data } = await admin.functions.invoke('daily-digest')
console.log(data) // { orders: 2, revenue_cents: 76200 }

On hosted Supabase the Vault project_url is your public project URL, so the same migration calls the function for real.

What carries over to production unchanged

Piece Local file On hosted Supabase
Schema and RLS supabase/migrations/*.sql supabase db push
Edge Functions supabase/functions/*/index.ts supabase functions deploy
Function secrets supabase/functions/.env supabase secrets set
Cron job migration using Vault same migration; set real Vault secrets
Webhook supabase/webhooks.json recreate in Dashboard, Database Webhooks

The one file that does not travel is webhooks.json, which is a local config format. Everything else is the stock Supabase layout, so there is no Tinbase-specific code to remove later. For more on the function side, Supabase's Edge Functions guide covers deployment and secrets.

Wrapping up

The async half of a Supabase backend has a lot of small handshakes: a payload shape, an auth header, a secret, a URL stored in Vault. Each one is easy to get wrong and hard to see in production. Running all of it in one local process turned three silent failures into clear error messages before any of it reached a real project.

How are you testing webhooks and cron-triggered functions today: locally, in a staging project, or by watching production logs?