Back to Blog
·Hook Mesh Team

Supabase Webhooks: Database Triggers and Edge Functions

Learn how to implement webhooks in Supabase using database triggers, pg_net extension, and Edge Functions. Complete guide with code examples for row changes, user sync, and audit logging.

Supabase Webhooks: Database Triggers and Edge Functions

Supabase Webhooks: Database Triggers and Edge Functions

Notify external services about database changes using Supabase webhooks. This guide covers database triggers with pg_net and Edge Functions for incoming webhook requests.

Understanding Webhook Options in Supabase

  • pg_net extension: Send HTTP requests directly from PostgreSQL
  • Database triggers: Automatically fire on row changes
  • Edge Functions: Serverless functions for receiving webhooks
  • Realtime subscriptions: WebSocket-based change notifications (for connected clients)

Use webhooks for external services that cannot maintain persistent connections.

Setting Up Database Webhooks with pg_net

pg_net allows PostgreSQL to make HTTP requests asynchronously from database triggers without blocking transactions.

Enable the extension:

-- Enable the pg_net extension
create extension if not exists pg_net;

Now you can make HTTP requests from SQL:

-- Simple POST request to an external webhook endpoint
select net.http_post(
  url := 'https://api.example.com/webhooks/user-created',
  headers := jsonb_build_object(
    'Content-Type', 'application/json',
    'Authorization', 'Bearer your-webhook-secret'
  ),
  body := jsonb_build_object(
    'event', 'user.created',
    'user_id', '123',
    'email', 'user@example.com',
    'timestamp', now()
  )
);

The request executes asynchronously, meaning your transaction completes immediately while pg_net handles the HTTP call in the background.

Database Triggers That Call Webhooks

Combine triggers with pg_net to notify external services about database changes. Example for user synchronization:

-- Create a function that sends webhooks on user changes
create or replace function notify_user_change()
returns trigger as $$
declare
  payload jsonb;
  webhook_url text := 'https://hooks.hookmesh.com/v1/your-endpoint-id';
begin
  -- Build the webhook payload
  payload := jsonb_build_object(
    'event', TG_OP,
    'table', TG_TABLE_NAME,
    'timestamp', extract(epoch from now()),
    'data', case
      when TG_OP = 'DELETE' then row_to_json(OLD)::jsonb
      else row_to_json(NEW)::jsonb
    end
  );

  -- Send the webhook via pg_net
  perform net.http_post(
    url := webhook_url,
    headers := jsonb_build_object(
      'Content-Type', 'application/json',
      'X-Webhook-Event', TG_OP,
      'X-Webhook-Signature', encode(
        hmac(payload::text, current_setting('app.webhook_secret'), 'sha256'),
        'hex'
      )
    ),
    body := payload
  );

  return coalesce(NEW, OLD);
end;
$$ language plpgsql security definer;

-- Attach the trigger to your users table
create trigger user_changes_webhook
  after insert or update or delete on public.users
  for each row execute function notify_user_change();

This trigger fires on any change to the users table and sends a webhook with the operation type, timestamp, and affected row data.

Edge Functions for Webhook Handling

Use Edge Functions to receive incoming webhooks from external services. They provide a serverless environment with database access. See webhooks with Lambda, Vercel, and Cloudflare for more serverless patterns.

// supabase/functions/webhook-handler/index.ts
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const supabaseUrl = Deno.env.get('SUPABASE_URL')!
const supabaseKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!

serve(async (req) => {
  // Verify webhook signature
  const signature = req.headers.get('x-webhook-signature')
  const body = await req.text()

  const expectedSignature = await computeHmac(
    body,
    Deno.env.get('WEBHOOK_SECRET')!
  )

  if (signature !== expectedSignature) {
    return new Response('Invalid signature', { status: 401 })
  }

  const payload = JSON.parse(body)
  const supabase = createClient(supabaseUrl, supabaseKey)

  // Process the webhook based on event type
  switch (payload.event) {
    case 'payment.completed':
      await supabase
        .from('orders')
        .update({ status: 'paid', paid_at: new Date().toISOString() })
        .eq('payment_id', payload.data.payment_id)
      break

    case 'subscription.canceled':
      await supabase
        .from('subscriptions')
        .update({ status: 'canceled', canceled_at: new Date().toISOString() })
        .eq('external_id', payload.data.subscription_id)
      break

    default:
      console.log('Unhandled event type:', payload.event)
  }

  // Log the webhook for debugging
  await supabase.from('webhook_logs').insert({
    event_type: payload.event,
    payload: payload,
    processed_at: new Date().toISOString()
  })

  return new Response(JSON.stringify({ received: true }), {
    headers: { 'Content-Type': 'application/json' }
  })
})

async function computeHmac(data: string, secret: string): Promise<string> {
  const encoder = new TextEncoder()
  const key = await crypto.subtle.importKey(
    'raw',
    encoder.encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign']
  )
  const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data))
  return Array.from(new Uint8Array(signature))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('')
}

Deploy the function with:

supabase functions deploy webhook-handler

Realtime Subscriptions vs Webhooks

Use Realtime when: Clients maintain WebSocket connections, need instant UI updates, can handle reconnection logic.

Use webhooks when: Notifying external services, systems cannot maintain WebSocket connections, needing guaranteed delivery with retries.

Many applications use both—Realtime for UI, webhooks for external sync.

Use Cases and Implementation Patterns

Row Changes to External Services

Sync product inventory changes:

create or replace function sync_inventory_changes()
returns trigger as $$
begin
  perform net.http_post(
    url := 'https://hooks.hookmesh.com/v1/inventory-sync',
    headers := '{"Content-Type": "application/json"}'::jsonb,
    body := jsonb_build_object(
      'product_id', NEW.id,
      'sku', NEW.sku,
      'quantity', NEW.quantity,
      'updated_at', NEW.updated_at
    )
  );
  return NEW;
end;
$$ language plpgsql;

User Sync to CRM

Auto-sync user signups. See Clerk webhooks for authentication layer sync:

create or replace function sync_user_to_crm()
returns trigger as $$
begin
  perform net.http_post(
    url := 'https://hooks.hookmesh.com/v1/crm-sync',
    body := jsonb_build_object(
      'action', 'create_contact',
      'email', NEW.email,
      'name', NEW.raw_user_meta_data->>'full_name',
      'created_at', NEW.created_at
    )
  );
  return NEW;
end;
$$ language plpgsql security definer;

create trigger new_user_crm_sync
  after insert on auth.users
  for each row execute function sync_user_to_crm();

Audit Logging

Create comprehensive audit logs for compliance:

create or replace function audit_log_webhook()
returns trigger as $$
begin
  perform net.http_post(
    url := 'https://hooks.hookmesh.com/v1/audit-log',
    body := jsonb_build_object(
      'table', TG_TABLE_NAME,
      'operation', TG_OP,
      'old_data', case when TG_OP != 'INSERT' then row_to_json(OLD) else null end,
      'new_data', case when TG_OP != 'DELETE' then row_to_json(NEW) else null end,
      'user_id', current_setting('request.jwt.claims', true)::jsonb->>'sub',
      'timestamp', now()
    )
  );
  return coalesce(NEW, OLD);
end;
$$ language plpgsql;

Best Practices for Supabase Webhooks

Use Edge Functions for receiving. They provide a secure, scalable endpoint with auth and database access. Always verify signatures. See webhook security best practices.

Use pg_net for sending. Asynchronous delivery prevents blocking database transactions. Failed requests don't roll back data changes.

Handle retries. pg_net doesn't auto-retry. For critical webhooks, use a dedicated service.

Implement idempotency. Include unique event IDs and check for duplicates on the receiving end. See webhook idempotency guide.

Monitor delivery. Log outbound webhooks and responses. Alert on failure spikes.

Reliable Webhook Delivery with Hook Mesh

pg_net works for simple scenarios, but production needs guaranteed delivery, retries, and logs. Hook Mesh provides enterprise-grade infrastructure for Supabase.

Route webhooks through Hook Mesh instead of direct delivery:

perform net.http_post(
  url := 'https://hooks.hookmesh.com/v1/your-endpoint-id',
  headers := jsonb_build_object(
    'Content-Type', 'application/json',
    'X-HookMesh-Target', 'https://api.their-service.com/webhook'
  ),
  body := your_payload
);

Hook Mesh handles retries with exponential backoff, provides status webhooks, and offers a monitoring dashboard. Valuable when external services go down—your triggers continue while Hook Mesh queues and retries.

Conclusion

Database triggers with pg_net enable outbound webhooks from PostgreSQL. Edge Functions handle incoming webhooks. Choose based on your need—and supplement with Hook Mesh for guaranteed delivery.

Start with these patterns, monitor delivery rates, and consider Hook Mesh for business-critical integrations.

For more guides, visit our Platform Integration Hub.

Related Posts