Agent skill

Supabase Webhooks Skill

Receive and verify Supabase webhooks. Use when setting up Supabase Database Webhooks (INSERT, UPDATE, DELETE table events sent via pg_net triggers) or Supabase Auth Hooks (send_email, send_sms, custom_access_token, before_user_created, mfa_verification_attempt, password_verification_attempt), debugging Standard Webhooks signature verification with the webhook-id, webhook-timestamp and webhook-signature headers, or handling the `v1,whsec_` secret format.

Install this skill

npx skills add hookdeck/webhook-skills --skill supabase-webhooks


When to Use This Skill

  • How do I receive Supabase Database Webhooks (INSERT / UPDATE / DELETE)?
  • How do I verify a Supabase Auth Hook signature?
  • Why is my Supabase webhook-signature verification failing?
  • How do I secure a Supabase Database Webhook when there is no signature?
  • How do I implement a send_email / send_sms / custom_access_token Auth Hook?
  • What does the v1,whsec_ secret prefix mean?

Two Surfaces, Two Security Models

Supabase sends outbound HTTP from two different systems. They do not share a security model — do not apply one's verification to the other.

Database WebhooksAuth Hooks (HTTP Hook)
SourcePostgres trigger → pg_netSupabase Auth (GoTrue)
DocsDatabase WebhooksAuth Hooks
EventsINSERT, UPDATE, DELETE6 auth lifecycle hooks
SignatureNone — no HMAC, no signing secret, no Supabase headerStandard Webhooks HMAC-SHA256
AuthWhatever headers you configure (e.g. Authorization: Bearer …)webhook-id / webhook-timestamp / webhook-signature
SemanticsFire-and-forget, asyncRequest/response — your JSON body changes auth behaviour
RetriesNone documentedUp to 3 retries (2s backoff, 5s total budget) — requires a non-empty retry-after header

Supabase documents no source-IP allowlist and no user-agent value for either surface. Do not build either into your receiver.

Verification (core)

Auth Hooks — Standard Webhooks HMAC-SHA256

const { Webhook } = require('standardwebhooks');

// Secret is issued as "v1,whsec_<base64>". Strip the "v1,whsec_" prefix; the
// remainder is STANDARD base64 that the library base64-DECODES to the raw HMAC
// key. Using the base64 string itself as the key rejects every real delivery.
const wh = new Webhook(process.env.SUPABASE_AUTH_HOOK_SECRET.replace('v1,whsec_', ''));

// Signs `{webhook-id}.{webhook-timestamp}.{raw_body}` and base64-compares in
// constant time against every space-delimited `v1,<sig>` entry, with a
// ±5-minute timestamp tolerance. Pass the RAW body — re-serialised JSON fails.
const payload = wh.verify(rawBody, {
  'webhook-id': headers['webhook-id'],
  'webhook-timestamp': headers['webhook-timestamp'],
  'webhook-signature': headers['webhook-signature'],
}); // throws WebhookVerificationError on failure

Database Webhooks — developer-configured shared secret

There is no signature to verify. Authenticate with a header you set yourself when creating the webhook, compared in constant time:

const crypto = require('crypto');

function timingSafeEqualStr(a, b) {
  const x = Buffer.from(a || '', 'utf8');
  const y = Buffer.from(b || '', 'utf8');
  if (x.length !== y.length) return false; // length is not secret here
  return crypto.timingSafeEqual(x, y);
}

// Header value comes from the headers JSON you pass to supabase_functions.http_request
function authenticateDatabaseWebhook(headers, secret) {
  if (!secret) return false;
  const authorization = headers['authorization'] || '';
  const presented = authorization.toLowerCase().startsWith('bearer ')
    ? authorization.slice(7).trim()
    : headers['x-webhook-secret'] || '';
  return timingSafeEqualStr(presented, secret);
}

if (!authenticateDatabaseWebhook(req.headers, process.env.SUPABASE_WEBHOOK_SECRET)) {
  return res.status(401).json({ error: 'Unauthorized' });
}

For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.

Database Webhook Events

Only three, all fired after the row change:

typeFires whenrecordold_record
INSERTA row is insertednew rownull
UPDATEA row is updatednew rowprevious row
DELETEA row is deletednulldeleted row

type is UPPERCASE and is the discriminator. The full payload has exactly four other top-level fields — there are no others:

{ "type": "INSERT", "table": "<table name>", "schema": "<schema name>", "record": { }, "old_record": null }

record / old_record mirror the table's own columns, so their inner shape is whatever your table defines.

Create one in the Dashboard (Integrations → Webhooks) or in SQL:

create trigger "my_webhook" after insert on "public"."my_table"
for each row execute function "supabase_functions"."http_request"(
  'https://example.com/webhooks/supabase',  -- url
  'POST',                                   -- method (POST or GET)
  '{"Content-Type":"application/json","Authorization":"Bearer YOUR_SHARED_SECRET"}',
  '{}',                                     -- params
  '1000'                                    -- timeout in ms
);

There is no delivery id header and no documented retry policypg_net is fire-and-forget within timeout_ms. Delivery history lives in the database's net schema. Idempotency is your receiver's job: dedupe on a primary key inside record / old_record.

Auth Hooks

Six hooks, config keys exactly as documented:

HookPlansRequest payloadYour response
before_user_createdFree, Pro{ metadata: { uuid, time, name, ip_address }, user }{} to allow; { "error": { "http_code": 400, "message": "…" } } to reject
custom_access_tokenFree, Pro{ user_id, claims, authentication_method }{ claims: { … } } to write into the JWT
send_smsFree, Pro{ user, sms: { otp } }{}you send the SMS
send_emailFree, Pro{ user, email_data: { token, token_hash, redirect_to, email_action_type, site_url, token_new, token_hash_new, old_email, old_phone, provider, factor_type } }{}you send the email
mfa_verification_attemptTeams, Enterprise{ factor_id, user_id, valid }{ decision: "continue" | "reject", message }
password_verification_attemptTeams, Enterprise{ user_id, valid }{ decision: "continue" | "reject", message, should_logout_user }

Auth Hooks are request/response, not fire-and-forget. The auth flow blocks on your reply and your JSON body changes what Supabase does. Errors are any status >= 400; a 429 or 503 is retried up to three times with a two-second backoff only if you also send a non-empty retry-after header (e.g. retry-after: true), inside a 5-second total budget for the whole invocation. Keep the handler fast and push slow work out of band. Always send Content-Type: application/json; 204 is rejected by custom_access_token, mfa_verification_attempt and password_verification_attempt, and 400 / 403 are turned into a 500 returned to your application.

Auth Hooks can alternatively be configured as a Postgres function (pg-functions://postgres/<schema>/<fn>), in which case no HTTP request leaves the instance and none of the above applies. This skill covers the HTTP variant.

Environment Variables

# Auth Hooks — the secret Supabase issues, including the "v1,whsec_" prefix
SUPABASE_AUTH_HOOK_SECRET=v1,whsec_UkxKUzBrOWt2c1hHTDF3YjNVSHhOZmw3Y0dyNXlKRHE=

# Database Webhooks — a shared secret YOU choose and put in the trigger's
# headers JSON. Supabase does not generate or sign anything here.
SUPABASE_WEBHOOK_SECRET=a-long-random-string-you-generate

Supabase's own config key for a hook secret is plural (e.g. SEND_SMS_HOOK_SECRETS) because multiple pipe-delimited secrets are planned for rotation. The webhook-signature header is already a space-delimited list of v1,<sig> entries for exactly that reason — accept if any entry matches.

Local Development

--path replaces the forwarded request path, so run one tunnel per surface:

# No install, no account required — creates a guest account on first run

# Database Webhooks (Express/Next.js on 3000; use 8000 for FastAPI)
npx hookdeck-cli listen 3000 supabase --path /webhooks/supabase

# Auth Hooks — a separate source, because --path replaces the request path
npx hookdeck-cli listen 3000 supabase-auth-hook --path /webhooks/supabase/auth-hook

Paste the first tunnel URL into the Dashboard (Integrations → Webhooks) and the second as the Auth Hook URI (Authentication → Hooks). One tunnel for both would route Auth Hooks into the Database Webhook handler and get a 401.

Reference Materials


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Aug 27, 2026

View on GitHub →