August 10, 2026 · Tutorial
Supabase E-Signature Pipeline: Database Webhooks & Edge Functions (2026)
Build an event-driven, production-grade document signing engine. Trigger Deno Edge Functions on Postgres table inserts, send contracts via Signbee's REST API, verify incoming HMAC-SHA256 webhook signatures, and push real-time signing state changes directly to your frontend.
Founder, Signbee
ARCHITECTURAL OVERVIEW
Modern document signing should be reactive and serverless. Instead of wiring up brittle cron jobs or polling third-party APIs, combine Supabase Postgres triggers, Deno Edge Functions, and Signbee. When a user drafts an agreement, a database webhook fires an Edge Function that issues a single POST https://signb.ee/api/v1/send call. When the recipient signs, Signbee dispatches a cryptographically verified webhook back to another Edge Function, which updates Postgres and broadcasts live status changes via Supabase Realtime.
Why Supabase + Signbee Is the Ideal E-Signature Stack
Most e-signature integrations suffer from architectural bloat. Traditional legacy providers require hundreds of lines of boilerplate SDK code, complex multi-step OAuth token exchanges, and manual polling loops to find out when a customer signed an NDA or sales agreement.
Combining Supabase (PostgreSQL, Row Level Security, Realtime, and Deno Edge Functions) with Signbee creates a lightweight, fully automated pipeline:
- Zero heavy client dependencies: All API dispatching and webhook signature verifications run inside Deno V8 edge isolates without node polyfills or external SDK packages.
- Event-driven database webhooks: Insert a row in your
contractstable from your app or CMS, and Supabase automatically orchestrates document creation. - Row Level Security (RLS) enforcement: Multi-tenant isolation is baked into Postgres, ensuring tenants can only view contracts and audit trails they own.
- Sub-second Realtime UI updates: Frontend clients (Next.js, React, Svelte, or mobile) receive instant status changes the moment a document is opened or executed.
System Architecture: Reactive Document Execution Flow
The entire pipeline operates on a bidirectional event-driven model. The diagram below illustrates the flow from initial contract creation through to final PDF delivery:
+------------------+ 1. INSERT (draft) +--------------------+
| Client Web App | -------------------------------> | Supabase Postgres |
| (Next.js/React) | | (contracts tab) |
+------------------+ +--------------------+
^ |
| | 2. DB Webhook Trigger
| v
| +--------------------+
| | Edge Function: |
| | send-contract |
| +--------------------+
| |
| | 3. POST /api/v1/send
| v
| +--------------------+
| | Signbee API |
| | (signb.ee v1) |
| +--------------------+
| |
| | 4. Email Signer
| v
| +--------------------+
| | Recipient Signs Doc|
| +--------------------+
| |
| | 5. Webhook (HMAC-SHA256)
| v
| +--------------------+
| | Edge Function: |
| | handle-webhook |
| +--------------------+
| |
| 7. Supabase Realtime Push (postgres_changes) | 6. UPDATE contracts
+------------------------------------------------------+ & INSERT audit_eventsIf you are looking to integrate e-signatures directly into a Next.js App Router frontend without Supabase triggers, check out our Next.js App Router E-Signature Guide or our Node.js E-Signature Tutorial. In this guide, we focus on autonomous Supabase backend infrastructure.
Step 1: Database Schema Design & Row Level Security (RLS)
A robust signing pipeline requires three relational tables in Postgres:
contracts: Stores document metadata, raw markdown content, current lifecycle status, and Signbee external IDs.signers: Tracks individual signer identities, their assigned roles, and signature timestamps.audit_events: An append-only historical ledger capturing every lifecycle transition (viewed, signed, declined, expired) for legal compliance.
Execute the following SQL migration in your Supabase SQL Editor to provision tables, indexes, and RLS policies:
-- Enable UUID generation extension
create extension if not exists "uuid-ossp";
-- 1. CONTRACTS TABLE
create table public.contracts (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users(id) on delete cascade not null,
title text not null,
content_markdown text not null,
status text not null default 'draft' check (
status in ('draft', 'pending_signature', 'viewed', 'signed', 'declined', 'expired')
),
signbee_document_id text unique,
signing_url text,
signed_pdf_url text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- 2. SIGNERS TABLE
create table public.signers (
id uuid primary key default gen_random_uuid(),
contract_id uuid references public.contracts(id) on delete cascade not null,
name text not null,
email text not null,
status text not null default 'pending' check (
status in ('pending', 'viewed', 'signed', 'declined')
),
signed_at timestamptz,
ip_address text,
user_agent text,
created_at timestamptz not null default now()
);
-- 3. AUDIT EVENTS TABLE (Immutable ledger)
create table public.audit_events (
id uuid primary key default gen_random_uuid(),
contract_id uuid references public.contracts(id) on delete cascade not null,
event_type text not null,
payload jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
-- Indexes for performance
create index idx_contracts_user_id on public.contracts(user_id);
create index idx_contracts_signbee_id on public.contracts(signbee_document_id);
create index idx_signers_contract_id on public.signers(contract_id);
create index idx_audit_events_contract_id on public.audit_events(contract_id);
-- Enable Row Level Security (RLS)
alter table public.contracts enable row level security;
alter table public.signers enable row level security;
alter table public.audit_events enable row level security;
-- Contracts RLS: Owners can read, create, and modify their own contracts
create policy "Users can view own contracts"
on public.contracts for select
using (auth.uid() = user_id);
create policy "Users can insert own contracts"
on public.contracts for insert
with check (auth.uid() = user_id);
create policy "Users can update own contracts"
on public.contracts for update
using (auth.uid() = user_id);
-- Signers RLS: Visible to contract owner
create policy "Users can view signers of own contracts"
on public.signers for select
using (
exists (
select 1 from public.contracts
where contracts.id = signers.contract_id
and contracts.user_id = auth.uid()
)
);
create policy "Users can insert signers for own contracts"
on public.signers for insert
with check (
exists (
select 1 from public.contracts
where contracts.id = signers.contract_id
and contracts.user_id = auth.uid()
)
);
-- Audit Events RLS: Read-only for contract owners
create policy "Users can view audit events for own contracts"
on public.audit_events for select
using (
exists (
select 1 from public.contracts
where contracts.id = audit_events.contract_id
and contracts.user_id = auth.uid()
)
);
-- Enable Supabase Realtime replication on contracts table
alter publication supabase_realtime add table public.contracts;
alter publication supabase_realtime add table public.audit_events;Notice that the audit_events table has select-only policies for authenticated users. Inserts and updates from incoming webhooks will be performed using the Supabase service_role key inside our Edge Function, bypassing user-scoped restrictions while preventing client-side forgery.
Step 2: Configuring Database Webhooks to Trigger Edge Functions
Supabase provides Database Webhooks powered by the pg_net Postgres extension. When a new row is inserted into contracts in draft state, Postgres automatically sends an HTTP request to your Edge Function.
You can configure this in the Supabase Dashboard under Database > Webhooks or via SQL:
-- Create trigger function invoking Edge Function
create or replace function public.trigger_send_contract_webhook()
returns trigger
language plpgsql
security definer
as $$
declare
edge_url text := 'https://<your-project-ref>.supabase.co/functions/v1/send-contract';
anon_key text := '<your-supabase-anon-or-service-key>';
begin
-- Only dispatch when contract is initially in 'draft' status
if new.status = 'draft' then
perform net.http_post(
url := edge_url,
headers := jsonb_build_object(
'Content-Type', 'application/json',
'Authorization', 'Bearer ' || anon_key
),
body := jsonb_build_object(
'type', 'INSERT',
'table', 'contracts',
'schema', 'public',
'record', row_to_json(new)
)
);
end if;
return new;
end;
$$;
-- Attach trigger to contracts table
create trigger on_contract_created_send_webhook
after insert on public.contracts
for each row
execute function public.trigger_send_contract_webhook();Step 3: Building the 'send-contract' Edge Function
Now, let's create the Deno Edge Function at supabase/functions/send-contract/index.ts. This function:
- Receives the webhook payload containing the new contract row.
- Fetches the associated signer from the
signerstable. - Calls the Signbee API endpoint
https://signb.ee/api/v1/sendwith markdown text and recipient information. - Updates the contract in Postgres with the returned
document_id,signing_url, and changes status topending_signature. - Inserts an initial audit trail entry.
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.0";
const SIGNBEE_API_KEY = Deno.env.get("SIGNBEE_API_KEY");
const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
interface WebhookPayload {
type: "INSERT" | "UPDATE";
table: string;
schema: string;
record: {
id: string;
user_id: string;
title: string;
content_markdown: string;
status: string;
};
}
serve(async (req: Request) => {
// Only handle POST requests
if (req.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { "Content-Type": "application/json" },
});
}
try {
if (!SIGNBEE_API_KEY || !SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) {
throw new Error("Missing required environment secrets.");
}
const payload: WebhookPayload = await req.json();
const contract = payload.record;
if (!contract || !contract.id) {
return new Response(JSON.stringify({ error: "Invalid contract payload" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
// Initialize Supabase admin client with service_role to bypass RLS
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
// Fetch the primary signer assigned to this contract
const { data: signers, error: signerError } = await supabase
.from("signers")
.select("id, name, email")
.eq("contract_id", contract.id)
.limit(1);
if (signerError || !signers || signers.length === 0) {
throw new Error(`No signer configured for contract ID: ${contract.id}`);
}
const primarySigner = signers[0];
// Call the Signbee API to dispatch document for signature
const signbeeResponse = await fetch("https://signb.ee/api/v1/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${SIGNBEE_API_KEY}`,
},
body: JSON.stringify({
markdown: contract.content_markdown,
recipient_name: primarySigner.name,
recipient_email: primarySigner.email,
}),
});
if (!signbeeResponse.ok) {
const errorText = await signbeeResponse.text();
throw new Error(`Signbee API returned status ${signbeeResponse.status}: ${errorText}`);
}
const signbeeData = await signbeeResponse.json();
// Expected response format: { document_id: "doc_xxx", signing_url: "https://..." }
// Update contract status in Supabase
const { error: updateError } = await supabase
.from("contracts")
.update({
signbee_document_id: signbeeData.document_id,
signing_url: signbeeData.signing_url,
status: "pending_signature",
updated_at: new Date().toISOString(),
})
.eq("id", contract.id);
if (updateError) {
throw new Error(`Failed to update contract: ${updateError.message}`);
}
// Append audit log
await supabase.from("audit_events").insert({
contract_id: contract.id,
event_type: "document.dispatched",
payload: {
signbee_document_id: signbeeData.document_id,
signer_email: primarySigner.email,
dispatched_at: new Date().toISOString(),
},
});
return new Response(
JSON.stringify({
success: true,
document_id: signbeeData.document_id,
signing_url: signbeeData.signing_url,
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
} catch (error: any) {
console.error("Error in send-contract Edge Function:", error.message);
return new Response(
JSON.stringify({ success: false, error: error.message }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
});Step 4: Building the Webhook Listener with HMAC-SHA256 Verification
When a recipient views, signs, or declines a document, Signbee sends an HTTP POST webhook request with the x-signbee-signature header.
To prevent spoofing or replay attacks, our second Edge Function at supabase/functions/handle-signbee-webhook/index.ts cryptographically verifies the signature using standard Web Crypto primitives (available natively in Deno).
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.0";
const SIGNBEE_WEBHOOK_SECRET = Deno.env.get("SIGNBEE_WEBHOOK_SECRET");
const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
// Helper: Convert hex string to Uint8Array
function hexToUint8Array(hexString: string): Uint8Array {
const cleanHex = hexString.replace(/^0x/, "");
const bytes = new Uint8Array(cleanHex.length / 2);
for (let i = 0; i < cleanHex.length; i += 2) {
bytes[i / 2] = parseInt(cleanHex.substring(i, i + 2), 16);
}
return bytes;
}
// Helper: Verify HMAC-SHA256 signature using native Web Crypto
async function verifyHmacSignature(
rawBody: string,
signatureHeader: string,
secret: string
): Promise<boolean> {
try {
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const key = await crypto.subtle.importKey(
"raw",
keyData,
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"]
);
const signatureBytes = hexToUint8Array(signatureHeader);
const bodyBytes = encoder.encode(rawBody);
return await crypto.subtle.verify(
"HMAC",
key,
signatureBytes,
bodyBytes
);
} catch (err) {
console.error("HMAC verification failed:", err);
return false;
}
}
serve(async (req: Request) => {
if (req.method !== "POST") {
return new Response("Method Not Allowed", { status: 405 });
}
try {
if (!SIGNBEE_WEBHOOK_SECRET || !SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) {
throw new Error("Missing required server environment variables.");
}
const signatureHeader = req.headers.get("x-signbee-signature");
if (!signatureHeader) {
return new Response(JSON.stringify({ error: "Missing signature header" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
// Read raw body text BEFORE parsing JSON for signature verification
const rawBody = await req.text();
const isValid = await verifyHmacSignature(
rawBody,
signatureHeader,
SIGNBEE_WEBHOOK_SECRET
);
if (!isValid) {
console.warn("Unauthorized webhook attempt detected with invalid signature.");
return new Response(JSON.stringify({ error: "Invalid HMAC signature" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
// Safely parse JSON payload
const event = JSON.parse(rawBody);
const { event_type, document_id, data } = event;
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
// Locate the contract by Signbee document ID
const { data: contract, error: contractError } = await supabase
.from("contracts")
.select("id, status")
.eq("signbee_document_id", document_id)
.single();
if (contractError || !contract) {
console.error(`Contract not found for document ID: ${document_id}`);
// Return 200 to acknowledge receipt so provider doesn't endlessly retry
return new Response(JSON.stringify({ received: true, warning: "Document not found" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
// Process event types
switch (event_type) {
case "document.viewed": {
await supabase
.from("contracts")
.update({ status: "viewed", updated_at: new Date().toISOString() })
.eq("id", contract.id);
await supabase
.from("signers")
.update({ status: "viewed" })
.eq("contract_id", contract.id);
break;
}
case "document.signed": {
await supabase
.from("contracts")
.update({
status: "signed",
signed_pdf_url: data?.pdf_url || null,
updated_at: new Date().toISOString(),
})
.eq("id", contract.id);
await supabase
.from("signers")
.update({
status: "signed",
signed_at: data?.signed_at || new Date().toISOString(),
ip_address: data?.signer_ip || null,
user_agent: data?.user_agent || null,
})
.eq("contract_id", contract.id);
break;
}
case "document.declined": {
await supabase
.from("contracts")
.update({ status: "declined", updated_at: new Date().toISOString() })
.eq("id", contract.id);
await supabase
.from("signers")
.update({ status: "declined" })
.eq("contract_id", contract.id);
break;
}
default:
console.log(`Unhandled webhook event type: ${event_type}`);
}
// Record immutable audit event
await supabase.from("audit_events").insert({
contract_id: contract.id,
event_type: event_type,
payload: event,
});
return new Response(JSON.stringify({ received: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error: any) {
console.error("Webhook processing error:", error.message);
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
});For a deep dive into webhook event schemas, payloads, and retry strategies, explore our guide on E-Signature Webhook Events & Payload Handling.
Step 5: Setting Secrets and Deploying to Supabase
Deploy your Edge Functions and configure the required environment secrets via the Supabase CLI:
# 1. Set environment secrets securely supabase secrets set SIGNBEE_API_KEY="sb_live_your_actual_api_key" supabase secrets set SIGNBEE_WEBHOOK_SECRET="whsec_your_webhook_signing_secret" # 2. Deploy both Edge Functions supabase functions deploy send-contract --no-verify-jwt supabase functions deploy handle-signbee-webhook --no-verify-jwt
Note: We include --no-verify-jwt for handle-signbee-webhook because incoming webhooks originate from Signbee's servers without Supabase Auth JWT tokens. Instead, the function relies on HMAC-SHA256 signature verification for zero-trust authentication.
Step 6: Real-Time UI Subscriptions on the Frontend
Because we enabled Realtime on the contracts table in our initial SQL migration, any React, Next.js, or mobile frontend can subscribe to live state updates without polling:
"use client";
import { useEffect, useState } from "react";
import { createClientComponentClient } from "@supabase/auth-helpers-nextjs";
interface Contract {
id: string;
title: string;
status: "draft" | "pending_signature" | "viewed" | "signed" | "declined";
signing_url?: string;
signed_pdf_url?: string;
}
export function LiveContractTracker({ initialContract }: { initialContract: Contract }) {
const [contract, setContract] = useState<Contract>(initialContract);
const supabase = createClientComponentClient();
useEffect(() => {
// Subscribe to Postgres replication events for this specific contract
const channel = supabase
.channel(`contract-status-${contract.id}`)
.on(
"postgres_changes",
{
event: "UPDATE",
schema: "public",
table: "contracts",
filter: `id=eq.${contract.id}`,
},
(payload) => {
const updated = payload.new as Contract;
setContract(updated);
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [contract.id, supabase]);
return (
<div className="border border-white/10 rounded-lg p-5 bg-zinc-900/50">
<div className="flex items-center justify-between">
<h3 className="font-medium text-white">{contract.title}</h3>
<span
className={`px-2.5 py-1 text-xs font-mono rounded-full ${
contract.status === "signed"
? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30"
: contract.status === "viewed"
? "bg-amber-500/20 text-amber-400 border border-amber-500/30"
: "bg-zinc-800 text-zinc-400 border border-zinc-700"
}`}
>
{contract.status.replace("_", " ").toUpperCase()}
</span>
</div>
{contract.status === "signed" && contract.signed_pdf_url && (
<a
href={contract.signed_pdf_url}
target="_blank"
rel="noopener noreferrer"
className="mt-4 inline-flex items-center gap-2 text-xs font-semibold text-amber-400 hover:text-amber-300"
>
Download Executed PDF →
</a>
)}
</div>
);
}Production Checklist & Error Recovery
Before taking this workflow live in production, implement these operational best practices:
1. Idempotency Keys in Webhook Handlers
Signbee guarantees at-least-once delivery for webhooks. If a transient network error delays an HTTP 200 response, duplicate events may arrive. Use the audit_events table with a unique constraint on (contract_id, event_type, (payload->>'timestamp')) to guarantee strict idempotency.
2. Rate Limit & Retry Backoff Handling
If your application dispatches high-volume batches of contracts simultaneously, respect HTTP 429 response headers by parsing Retry-After. Learn more about concurrency limits in our Batch E-Signature API Guide.
3. Dynamic Markdown PDF Styling
Format your contracts with dynamic markdown clauses, page breaks, and legal boilerplate before passing them to send-contract. Review our Markdown to PDF Styling Guide for layout tips.
Frequently Asked Questions
Why use Supabase Edge Functions instead of client-side API calls for e-signatures?
Using Supabase Edge Functions decouples your frontend client from secret management and document generation logic. If you trigger signature requests directly from a client application (like a React or mobile client), you risk exposing your private Signbee API token or relying on insecure client-side status updates. Edge Functions run in a secure Deno V8 isolate close to your users, pulling credentials safely from encrypted environment secrets (Deno.env.get). Furthermore, triggering Edge Functions via Supabase Database Webhooks guarantees that contract creation is atomic: a contract record only enters the signature queue once it is committed to Postgres. This architectural boundary ensures zero secret leakage, consistent business validation, and tamper-proof audit trail ingestion.
How do you securely verify Signbee webhooks inside a Deno Supabase Edge Function without Node.js dependencies?
Supabase Edge Functions run on Deno, which provides native Web Crypto API support (crypto.subtle). To verify Signbee webhooks securely, extract the raw payload string using await req.text() prior to JSON parsing. Then, retrieve the signature from the x-signbee-signature header. Using crypto.subtle.importKey with HMAC SHA-256 and your SIGNBEE_WEBHOOK_SECRET, compute the cryptographic digest of the raw body and compare it with crypto.subtle.verify or a constant-time comparison helper. This native Web Crypto implementation requires zero external npm or Node.js polyfills, runs in sub-millisecond execution times, and protects against timing attacks, replay tampering, and unauthorized data injection into your Supabase database.
How does Supabase Realtime propagate e-signature status updates to connected clients?
Supabase Realtime listens to PostgreSQL logical replication logs (WAL) via the supabase_realtime publication. When Signbee dispatches a document.signed or document.viewed webhook event to your handle-signbee-webhook Edge Function, the function validates the signature and executes an UPDATE query on your public.contracts table using the Supabase Service Role client. Postgres immediately emits an UPDATE event through replication. Connected frontend clients (Next.js, React, React Native, or Vue) subscribed via supabase.channel().on('postgres_changes', ...) receive the updated status, timestamp, and signed PDF URL in under 100ms without needing manual polling or custom WebSocket servers.
Ready to automate e-signatures in your Supabase backend? Get started with 5 free documents per month.
Last updated: August 10, 2026 · Michael Beckett is the founder of Signbee and B2bee Ltd.