# Test Supabase Edge Functions, Webhooks and Cron Locally Without Docker

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](https://www.tinbase.dev/?utm_source=hashnode&utm_medium=blog&utm_campaign=supabase-edge-functions-webhooks-cron-without-docker), 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.

```bash
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:

```text
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

```sql
-- 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

```ts
// 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:

```bash
# 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`:

```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](https://supabase.com/docs/guides/database/webhooks) in the dashboard with your real project's key.

Now start the backend:

```bash
npx tinbase start
```

The startup banner should list both functions and the webhook:

```text
  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:

```ts
// 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):

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

Output from a real run:

```text
[
  { 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](https://supabase.com/docs/guides/cron): read the project URL and key from Vault, then `net.http_post` to the function.

```sql
-- 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
  );
  $$
);
```

```sql
-- 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:

```ts
// 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:

```sql
select status_code, error_msg from net._http_response order by id desc limit 1;
```

```text
 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:
    

```ts
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](https://supabase.com/docs/guides/functions) 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?
