From df55c5c3276994a714a79cd7dd5a0d7cde702345 Mon Sep 17 00:00:00 2001
From: Danny White <3104761+dnywh@users.noreply.github.com>
Date: Fri, 6 Mar 2026 13:56:54 +1100
Subject: [PATCH] feat(studio): platform webhooks scaffolding (#43276)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## What kind of change does this PR introduce?
Impending feature addition. Resolves DEPR-340.
## What is the current behavior?
We don’t have any platform webhook support.
## What is the new behavior?
This puts the scaffolding for platform webhooks **behind a feature
flag**. The content is currently UI-only (with mock data and a
[temporary tracking
file](https://github.com/supabase/supabase/blob/8adadc61f57c70b686a0f5f05e9f7f05837e0466/apps/studio/components/interfaces/Platform/Webhooks/DEPR-340-backend-integration-tracker.md)).
Merging this in lets us work incrementally from here on.
---
.../App/CommandMenu/CreateCommands.utils.tsx | 2 +-
.../FeaturePreview/FeaturePreviewContext.tsx | 6 +
.../FeaturePreview/FeaturePreviewModal.tsx | 2 +
.../PlatformWebhooksPreview.tsx | 26 +
.../App/FeaturePreview/useFeaturePreviews.ts | 10 +
.../Webhooks/PlatformWebhooks.mock.ts | 124 ++++
.../Webhooks/PlatformWebhooks.store.ts | 267 ++++++++
.../Webhooks/PlatformWebhooks.types.ts | 50 ++
.../PlatformWebhooksDeliveryDetailsSheet.tsx | 187 ++++++
.../PlatformWebhooksEndpointDetails.tsx | 175 ++++++
.../Webhooks/PlatformWebhooksEndpointList.tsx | 232 +++++++
.../PlatformWebhooksEndpointSheet.tsx | 581 ++++++++++++++++++
.../Webhooks/PlatformWebhooksHeader.tsx | 91 +++
.../Webhooks/PlatformWebhooksPage.tsx | 483 +++++++++++++++
.../PlatformWebhooksPage.utils.test.ts | 87 +++
.../Webhooks/PlatformWebhooksPage.utils.ts | 47 ++
.../Webhooks/PlatformWebhooksView.utils.ts | 31 +
.../interfaces/Platform/Webhooks/index.ts | 11 +
apps/studio/components/interfaces/Sidebar.tsx | 6 +-
.../DatabaseLayout/Database.Commands.tsx | 7 -
.../DatabaseLayout/DatabaseMenu.utils.tsx | 2 +-
.../NavigationBar/NavigationBar.utils.tsx | 10 +-
.../OrganizationSettingsLayout.test.ts | 32 +
.../OrganizationSettingsLayout.tsx | 98 ++-
.../ProjectSettings.Commands.tsx | 27 +-
.../ProjectSettingsLayout/SettingsLayout.tsx | 3 +
.../SettingsMenu.utils.test.tsx | 43 ++
.../SettingsMenu.utils.tsx | 13 +
.../org/[slug]/webhooks/[endpointId].tsx | 24 +
.../pages/org/[slug]/webhooks/index.tsx | 19 +
.../[ref]/settings/webhooks/[endpointId].tsx | 21 +
.../project/[ref]/settings/webhooks/index.tsx | 16 +
packages/common/constants/local-storage.ts | 2 +
33 files changed, 2713 insertions(+), 22 deletions(-)
create mode 100644 apps/studio/components/interfaces/App/FeaturePreview/PlatformWebhooksPreview.tsx
create mode 100644 apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.mock.ts
create mode 100644 apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.store.ts
create mode 100644 apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.types.ts
create mode 100644 apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksDeliveryDetailsSheet.tsx
create mode 100644 apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx
create mode 100644 apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointList.tsx
create mode 100644 apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx
create mode 100644 apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksHeader.tsx
create mode 100644 apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.tsx
create mode 100644 apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.utils.test.ts
create mode 100644 apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.utils.ts
create mode 100644 apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksView.utils.ts
create mode 100644 apps/studio/components/interfaces/Platform/Webhooks/index.ts
create mode 100644 apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.test.tsx
create mode 100644 apps/studio/pages/org/[slug]/webhooks/[endpointId].tsx
create mode 100644 apps/studio/pages/org/[slug]/webhooks/index.tsx
create mode 100644 apps/studio/pages/project/[ref]/settings/webhooks/[endpointId].tsx
create mode 100644 apps/studio/pages/project/[ref]/settings/webhooks/index.tsx
diff --git a/apps/studio/components/interfaces/App/CommandMenu/CreateCommands.utils.tsx b/apps/studio/components/interfaces/App/CommandMenu/CreateCommands.utils.tsx
index 4c7adb5bdea..c3493239a28 100644
--- a/apps/studio/components/interfaces/App/CommandMenu/CreateCommands.utils.tsx
+++ b/apps/studio/components/interfaces/App/CommandMenu/CreateCommands.utils.tsx
@@ -74,7 +74,7 @@ export function getIntegrationCommandName(integration: IntegrationDefinition): s
case 'cron':
return 'Create Cron Job'
case 'webhooks':
- return 'Create Webhook'
+ return 'Create Database Webhook'
case 'queues':
return 'Create Queue'
default:
diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx
index 0ec643b3029..7fbd506af9f 100644
--- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx
+++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx
@@ -112,6 +112,12 @@ export const useIsQueueOperationsEnabled = () => {
return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_QUEUE_OPERATIONS]
}
+export const useIsPlatformWebhooksEnabled = () => {
+ const { flags } = useFeaturePreviewContext()
+ const platformWebhooksEnabled = useFlag('platformWebhooks')
+ return platformWebhooksEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS]
+}
+
export const useIsTableFilterBarEnabled = () => {
const { flags } = useFeaturePreviewContext()
return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_TABLE_FILTER_BAR]
diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx
index 2c4c5f7a2ba..faaeae557e0 100644
--- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx
+++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx
@@ -24,6 +24,7 @@ import { APISidePanelPreview } from './APISidePanelPreview'
import { Branching2Preview } from './Branching2Preview'
import { CLSPreview } from './CLSPreview'
import { useFeaturePreviewContext, useFeaturePreviewModal } from './FeaturePreviewContext'
+import { PlatformWebhooksPreview } from './PlatformWebhooksPreview'
import { PgDeltaDiffPreview } from './PgDeltaDiffPreview'
import { QueueOperationsPreview } from './QueueOperationsPreview'
import { TableFilterBarPreview } from './TableFilterBarPreview'
@@ -42,6 +43,7 @@ const FEATURE_PREVIEW_KEY_TO_CONTENT: {
[LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS]: ,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_QUEUE_OPERATIONS]: ,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_TABLE_FILTER_BAR]: ,
+ [LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS]: ,
}
export const FeaturePreviewModal = () => {
diff --git a/apps/studio/components/interfaces/App/FeaturePreview/PlatformWebhooksPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/PlatformWebhooksPreview.tsx
new file mode 100644
index 00000000000..a0824e80246
--- /dev/null
+++ b/apps/studio/components/interfaces/App/FeaturePreview/PlatformWebhooksPreview.tsx
@@ -0,0 +1,26 @@
+import { useParams } from 'common'
+
+import { InlineLink } from '@/components/ui/InlineLink'
+
+export const PlatformWebhooksPreview = () => {
+ const { slug = '_', ref = '_' } = useParams()
+
+ return (
+
+
+ Configure webhook endpoints and review deliveries from both project and organization
+ settings pages.
+
+
+ -
+ Project scope:{' '}
+ Project Webhooks
+
+ -
+ Organization scope:{' '}
+ Organization Webhooks
+
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts
index c0c957bce91..df3f9535d6c 100644
--- a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts
+++ b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts
@@ -17,6 +17,7 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
const isUnifiedLogsPreviewAvailable = useFlag('unifiedLogs')
const tableEditorNewFilterBar = useFlag('tableEditorNewFilterBar')
const pgDeltaDiffEnabled = useFlag('pgdeltaDiff')
+ const platformWebhooksEnabled = useFlag('platformWebhooks')
return [
{
@@ -56,6 +57,15 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
isDefaultOptIn: true,
enabled: pgDeltaDiffEnabled,
},
+ {
+ key: LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS,
+ name: 'Platform webhooks',
+ discussionsUrl: undefined,
+ isNew: true,
+ isPlatformOnly: true,
+ isDefaultOptIn: false,
+ enabled: platformWebhooksEnabled,
+ },
{
key: LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL,
name: 'Project API documentation',
diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.mock.ts b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.mock.ts
new file mode 100644
index 00000000000..1a10d55d272
--- /dev/null
+++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.mock.ts
@@ -0,0 +1,124 @@
+import type { PlatformWebhooksMockSeed, WebhookScope } from './PlatformWebhooks.types'
+
+export const PLATFORM_WEBHOOKS_MOCK_DATA: Record = {
+ organization: {
+ eventTypes: [
+ 'organization.member_invited',
+ 'organization.member_removed',
+ 'organization.updated',
+ 'project.created',
+ 'project.updated',
+ 'project.deleted',
+ 'project.paused',
+ 'project.resumed',
+ ],
+ endpoints: [
+ {
+ id: 'org-endpoint-1',
+ name: 'Lovable production',
+ url: 'https://api.lovable.dev/webhooks/supabase',
+ description: 'Primary organization webhook endpoint',
+ enabled: true,
+ eventTypes: ['project.created', 'project.updated', 'project.deleted'],
+ customHeaders: [
+ { id: 'org-header-1', key: 'X-Lovable-Version', value: 'v2' },
+ { id: 'org-header-2', key: 'X-Environment', value: 'production' },
+ ],
+ createdBy: 'user@supabase.io',
+ createdAt: '2026-02-15T21:30:00.000Z',
+ },
+ {
+ id: 'org-endpoint-2',
+ name: 'Slack notifications',
+ url: 'https://hooks.slack.com/services/T000/B000/ORG',
+ description: 'Operational alerts for organization-level events',
+ enabled: false,
+ eventTypes: ['organization.updated'],
+ customHeaders: [],
+ createdBy: 'ops@supabase.io',
+ createdAt: '2026-02-12T10:14:00.000Z',
+ },
+ ],
+ deliveries: [
+ {
+ id: 'org-delivery-1',
+ endpointId: 'org-endpoint-1',
+ eventType: 'project.created',
+ status: 'success',
+ responseCode: 200,
+ attemptAt: '2026-02-27T08:04:00.000Z',
+ },
+ {
+ id: 'org-delivery-2',
+ endpointId: 'org-endpoint-1',
+ eventType: 'project.updated',
+ status: 'failure',
+ responseCode: 500,
+ attemptAt: '2026-02-27T07:56:00.000Z',
+ },
+ {
+ id: 'org-delivery-3',
+ endpointId: 'org-endpoint-1',
+ eventType: 'project.deleted',
+ status: 'pending',
+ attemptAt: '2026-02-27T07:45:00.000Z',
+ },
+ {
+ id: 'org-delivery-4',
+ endpointId: 'org-endpoint-2',
+ eventType: 'organization.updated',
+ status: 'skipped',
+ attemptAt: '2026-02-26T14:12:00.000Z',
+ },
+ ],
+ },
+ project: {
+ eventTypes: [
+ 'project.updated',
+ 'project.paused',
+ 'project.resumed',
+ 'project.branch_created',
+ 'project.branch_deleted',
+ 'project.resource_exhausted',
+ ],
+ endpoints: [
+ {
+ id: 'project-endpoint-1',
+ name: 'Project analytics',
+ url: 'https://analytics.example.com/hooks/supabase-project',
+ description: 'Project-level status updates for analytics pipeline',
+ enabled: true,
+ eventTypes: ['project.updated', 'project.resource_exhausted'],
+ customHeaders: [{ id: 'project-header-1', key: 'X-Project-Type', value: 'platform' }],
+ createdBy: 'owner@project.dev',
+ createdAt: '2026-02-18T11:23:00.000Z',
+ },
+ ],
+ deliveries: [
+ {
+ id: 'project-delivery-1',
+ endpointId: 'project-endpoint-1',
+ eventType: 'project.updated',
+ status: 'success',
+ responseCode: 200,
+ attemptAt: '2026-02-27T09:01:00.000Z',
+ },
+ {
+ id: 'project-delivery-2',
+ endpointId: 'project-endpoint-1',
+ eventType: 'project.resource_exhausted',
+ status: 'failure',
+ responseCode: 429,
+ attemptAt: '2026-02-27T08:47:00.000Z',
+ },
+ {
+ id: 'project-delivery-3',
+ endpointId: 'project-endpoint-1',
+ eventType: 'project.paused',
+ status: 'success',
+ responseCode: 202,
+ attemptAt: '2026-02-26T21:10:00.000Z',
+ },
+ ],
+ },
+}
diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.store.ts b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.store.ts
new file mode 100644
index 00000000000..a73e6461c46
--- /dev/null
+++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.store.ts
@@ -0,0 +1,267 @@
+import { useEffect, useState } from 'react'
+
+import { PLATFORM_WEBHOOKS_MOCK_DATA } from './PlatformWebhooks.mock'
+import type {
+ PlatformWebhooksState,
+ UpsertWebhookEndpointInput,
+ WebhookDelivery,
+ WebhookEndpoint,
+ WebhookScope,
+} from './PlatformWebhooks.types'
+
+interface CreateEndpointOptions {
+ now?: string
+ endpointId?: string
+ createdBy?: string
+ signingSecret?: string
+}
+
+interface UpdateEndpointOptions {
+ headerIdFactory?: () => string
+}
+
+const secureRandomHex = (length: number) => {
+ if (length <= 0) return ''
+
+ const cryptoApi = globalThis.crypto
+ if (!cryptoApi?.getRandomValues) {
+ throw new Error('Web Crypto API is not available')
+ }
+
+ const byteCount = Math.ceil(length / 2)
+ const bytes = new Uint8Array(byteCount)
+ cryptoApi.getRandomValues(bytes)
+
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0'))
+ .join('')
+ .slice(0, length)
+}
+
+const randomId = (prefix: string) => `${prefix}-${secureRandomHex(8)}`
+
+const generateSigningSecret = () => `whsec_${secureRandomHex(16)}`
+
+const deepClone = (value: T): T => JSON.parse(JSON.stringify(value))
+
+const toHeaders = (
+ headers: UpsertWebhookEndpointInput['customHeaders'],
+ options?: UpdateEndpointOptions
+) => {
+ const headerIdFactory = options?.headerIdFactory ?? (() => randomId('header'))
+ return headers
+ .map((header) => ({
+ id: headerIdFactory(),
+ key: header.key.trim(),
+ value: header.value.trim(),
+ }))
+ .filter((header) => header.key.length > 0 && header.value.length > 0)
+}
+
+const normalizeSearch = (value: string) => value.trim().toLowerCase()
+
+export const createInitialPlatformWebhooksState = (scope: WebhookScope): PlatformWebhooksState => {
+ const seed = PLATFORM_WEBHOOKS_MOCK_DATA[scope]
+ return {
+ endpoints: deepClone(seed.endpoints),
+ deliveries: deepClone(seed.deliveries),
+ }
+}
+
+const persistedMockStateByScope: Partial> = {}
+
+const getPersistedMockState = (scope: WebhookScope) => {
+ const persistedState = persistedMockStateByScope[scope]
+ if (persistedState) return persistedState
+
+ const initialState = createInitialPlatformWebhooksState(scope)
+ persistedMockStateByScope[scope] = initialState
+ return initialState
+}
+
+// Test-only helper to avoid cross-test state leakage in hook tests.
+export const resetPlatformWebhooksMockStateForTests = (scope?: WebhookScope) => {
+ if (scope) {
+ delete persistedMockStateByScope[scope]
+ return
+ }
+
+ for (const key of Object.keys(persistedMockStateByScope) as WebhookScope[]) {
+ delete persistedMockStateByScope[key]
+ }
+}
+
+export const createWebhookEndpoint = (
+ state: PlatformWebhooksState,
+ input: UpsertWebhookEndpointInput,
+ options?: CreateEndpointOptions
+): { state: PlatformWebhooksState; endpoint: WebhookEndpoint; signingSecret: string } => {
+ const endpointId = options?.endpointId ?? randomId('endpoint')
+ const internalName = input.name.trim().length > 0 ? input.name.trim() : endpointId
+ const signingSecret = options?.signingSecret ?? generateSigningSecret()
+ const endpoint: WebhookEndpoint = {
+ id: endpointId,
+ name: internalName,
+ url: input.url.trim(),
+ description: input.description.trim(),
+ enabled: input.enabled,
+ eventTypes: input.eventTypes.length > 0 ? input.eventTypes : ['*'],
+ customHeaders: toHeaders(input.customHeaders),
+ createdBy: options?.createdBy ?? 'mock-user@supabase.io',
+ createdAt: options?.now ?? new Date().toISOString(),
+ }
+
+ return {
+ endpoint,
+ signingSecret,
+ state: {
+ ...state,
+ endpoints: [endpoint, ...state.endpoints],
+ },
+ }
+}
+
+export const updateWebhookEndpoint = (
+ state: PlatformWebhooksState,
+ endpointId: string,
+ input: UpsertWebhookEndpointInput,
+ options?: UpdateEndpointOptions
+) => {
+ return {
+ ...state,
+ endpoints: state.endpoints.map((endpoint) =>
+ endpoint.id === endpointId
+ ? {
+ ...endpoint,
+ name: input.name.trim().length > 0 ? input.name.trim() : endpoint.name,
+ url: input.url.trim(),
+ description: input.description.trim(),
+ enabled: input.enabled,
+ eventTypes: input.eventTypes.length > 0 ? input.eventTypes : ['*'],
+ customHeaders: toHeaders(input.customHeaders, options),
+ }
+ : endpoint
+ ),
+ }
+}
+
+export const deleteWebhookEndpoint = (state: PlatformWebhooksState, endpointId: string) => {
+ return {
+ endpoints: state.endpoints.filter((endpoint) => endpoint.id !== endpointId),
+ deliveries: state.deliveries.filter((delivery) => delivery.endpointId !== endpointId),
+ }
+}
+
+export const toggleWebhookEndpoint = (
+ state: PlatformWebhooksState,
+ endpointId: string,
+ enabled?: boolean
+) => {
+ return {
+ ...state,
+ endpoints: state.endpoints.map((endpoint) =>
+ endpoint.id === endpointId
+ ? { ...endpoint, enabled: enabled === undefined ? !endpoint.enabled : enabled }
+ : endpoint
+ ),
+ }
+}
+
+export const regenerateWebhookEndpointSecret = (
+ state: PlatformWebhooksState,
+ endpointId: string,
+ secret?: string
+) => {
+ const endpointExists = state.endpoints.some((endpoint) => endpoint.id === endpointId)
+ if (!endpointExists) return { state, signingSecret: null }
+
+ return {
+ state: { ...state },
+ signingSecret: secret ?? generateSigningSecret(),
+ }
+}
+
+export const filterWebhookEndpoints = (endpoints: WebhookEndpoint[], search: string) => {
+ const normalizedSearch = normalizeSearch(search)
+ if (normalizedSearch.length === 0) return endpoints
+
+ return endpoints.filter((endpoint) => {
+ const haystack = `${endpoint.name} ${endpoint.url} ${endpoint.description}`.toLowerCase()
+ return haystack.includes(normalizedSearch)
+ })
+}
+
+export const filterWebhookDeliveries = (
+ deliveries: WebhookDelivery[],
+ endpointId: string,
+ search: string
+) => {
+ const normalizedSearch = normalizeSearch(search)
+
+ return deliveries
+ .filter((delivery) => delivery.endpointId === endpointId)
+ .filter((delivery) => {
+ if (normalizedSearch.length === 0) return true
+ const haystack =
+ `${delivery.eventType} ${delivery.status} ${delivery.responseCode ?? ''}`.toLowerCase()
+ return haystack.includes(normalizedSearch)
+ })
+}
+
+export const usePlatformWebhooksMockStore = (scope: WebhookScope) => {
+ const [state, setState] = useState(() =>
+ deepClone(getPersistedMockState(scope))
+ )
+
+ useEffect(() => {
+ setState(deepClone(getPersistedMockState(scope)))
+ }, [scope])
+
+ const applyStateUpdate = (
+ updater: (previous: PlatformWebhooksState) => PlatformWebhooksState
+ ) => {
+ setState((previous) => {
+ const next = updater(previous)
+ persistedMockStateByScope[scope] = next
+ return next
+ })
+ }
+
+ return {
+ ...state,
+ createEndpoint: (input: UpsertWebhookEndpointInput) => {
+ const endpointId = randomId('endpoint')
+ const now = new Date().toISOString()
+ const signingSecret = generateSigningSecret()
+ const createdBy = 'mock-user@supabase.io'
+ let createdSecret = signingSecret
+ applyStateUpdate((prev) => {
+ const next = createWebhookEndpoint(prev, input, {
+ endpointId,
+ now,
+ signingSecret,
+ createdBy,
+ })
+ createdSecret = next.signingSecret
+ return next.state
+ })
+ return { endpointId, signingSecret: createdSecret }
+ },
+ updateEndpoint: (endpointId: string, input: UpsertWebhookEndpointInput) => {
+ applyStateUpdate((prev) => updateWebhookEndpoint(prev, endpointId, input))
+ },
+ deleteEndpoint: (endpointId: string) => {
+ applyStateUpdate((prev) => deleteWebhookEndpoint(prev, endpointId))
+ },
+ toggleEndpoint: (endpointId: string, enabled?: boolean) => {
+ applyStateUpdate((prev) => toggleWebhookEndpoint(prev, endpointId, enabled))
+ },
+ regenerateSecret: (endpointId: string) => {
+ const currentState = persistedMockStateByScope[scope] ?? state
+ const next = regenerateWebhookEndpointSecret(currentState, endpointId)
+ if (!next.signingSecret) return null
+
+ applyStateUpdate(() => next.state)
+ return next.signingSecret
+ },
+ }
+}
diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.types.ts b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.types.ts
new file mode 100644
index 00000000000..b98c56268db
--- /dev/null
+++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.types.ts
@@ -0,0 +1,50 @@
+export type WebhookScope = 'organization' | 'project'
+
+export type WebhookDeliveryStatus = 'pending' | 'success' | 'failure' | 'skipped'
+
+export interface WebhookHeader {
+ id: string
+ key: string
+ value: string
+}
+
+export interface WebhookEndpoint {
+ id: string
+ name: string
+ url: string
+ description: string
+ enabled: boolean
+ eventTypes: string[]
+ customHeaders: WebhookHeader[]
+ createdBy: string
+ createdAt: string
+}
+
+export interface WebhookDelivery {
+ id: string
+ endpointId: string
+ eventType: string
+ status: WebhookDeliveryStatus
+ responseCode?: number
+ attemptAt: string
+}
+
+export interface PlatformWebhooksMockSeed {
+ eventTypes: string[]
+ endpoints: WebhookEndpoint[]
+ deliveries: WebhookDelivery[]
+}
+
+export interface PlatformWebhooksState {
+ endpoints: WebhookEndpoint[]
+ deliveries: WebhookDelivery[]
+}
+
+export interface UpsertWebhookEndpointInput {
+ name: string
+ url: string
+ description: string
+ enabled: boolean
+ eventTypes: string[]
+ customHeaders: Array>
+}
diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksDeliveryDetailsSheet.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksDeliveryDetailsSheet.tsx
new file mode 100644
index 00000000000..ab0b57a8c91
--- /dev/null
+++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksDeliveryDetailsSheet.tsx
@@ -0,0 +1,187 @@
+import { Copy } from 'lucide-react'
+
+import { getStatusLevel } from 'components/interfaces/UnifiedLogs/UnifiedLogs.utils'
+import { DataTableColumnStatusCode } from 'components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode'
+import {
+ AlertDialog,
+ Badge,
+ Button,
+ Card,
+ CardContent,
+ Separator,
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetSection,
+ SheetTitle,
+ Tabs_Shadcn_ as Tabs,
+ TabsContent_Shadcn_ as TabsContent,
+ TabsList_Shadcn_ as TabsList,
+ TabsTrigger_Shadcn_ as TabsTrigger,
+} from 'ui'
+import { TimestampInfo } from 'ui-patterns'
+import type { WebhookDelivery } from './PlatformWebhooks.types'
+import { formatDeliveryStatus, statusBadgeVariant } from './PlatformWebhooksView.utils'
+
+interface PlatformWebhooksDeliveryDetailsSheetProps {
+ deliveryAttempt: number | null
+ deliveryDetailsTab: 'event' | 'response'
+ deliveryEventPayload: string
+ deliveryResponsePayload: string
+ open: boolean
+ selectedDelivery: WebhookDelivery | null
+ onCopy: (value: string, label: string) => void
+ onOpenChange: (open: boolean) => void
+ onTabChange: (tab: 'event' | 'response') => void
+}
+
+export const PlatformWebhooksDeliveryDetailsSheet = ({
+ deliveryAttempt,
+ deliveryDetailsTab,
+ deliveryEventPayload,
+ deliveryResponsePayload,
+ open,
+ selectedDelivery,
+ onCopy,
+ onOpenChange,
+ onTabChange,
+}: PlatformWebhooksDeliveryDetailsSheetProps) => {
+ return (
+
+
+
+
+ Delivery details
+ {selectedDelivery && (
+
+ {formatDeliveryStatus(selectedDelivery.status)}
+
+ )}
+
+
+
+
+ {selectedDelivery && (
+
+
+
+
+
+
Delivery ID
+
{selectedDelivery.id}
+
+
+
Attempt
+
+ {deliveryAttempt ? `#${deliveryAttempt}` : '-'}
+
+
+
+
+
Response code
+ {selectedDelivery.responseCode ? (
+
+ ) : (
+
–
+ )}
+
+
+
+
+
onTabChange(value as 'event' | 'response')}
+ >
+
+
+ Event
+
+
+ Response
+
+
+
+
+
+
Event type
+
{selectedDelivery.eventType}
+
+
+
Event ID
+
{selectedDelivery.id}
+
+
+
+
+
Payload
+
}
+ onClick={() => onCopy(deliveryEventPayload, 'event payload')}
+ >
+ Copy
+
+
+
+
+ {deliveryEventPayload}
+
+
+
+
+
+
+
+
Status
+
+ {formatDeliveryStatus(selectedDelivery.status)}
+
+
+
+
Response code
+ {selectedDelivery.responseCode ? (
+
+ ) : (
+
–
+ )}
+
+
+
+
Response payload
+
}
+ onClick={() => onCopy(deliveryResponsePayload, 'response payload')}
+ >
+ Copy
+
+
+
+
+ {deliveryResponsePayload}
+
+
+
+
+
+
+
+ )}
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx
new file mode 100644
index 00000000000..6d84c04bcee
--- /dev/null
+++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx
@@ -0,0 +1,175 @@
+import { Search } from 'lucide-react'
+
+import { getStatusLevel } from 'components/interfaces/UnifiedLogs/UnifiedLogs.utils'
+import { DataTableColumnStatusCode } from 'components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode'
+import {
+ Badge,
+ Card,
+ CardContent,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from 'ui'
+import { TimestampInfo } from 'ui-patterns'
+import { Input } from 'ui-patterns/DataInputs/Input'
+import type { WebhookDelivery, WebhookEndpoint } from './PlatformWebhooks.types'
+import { statusBadgeVariant } from './PlatformWebhooksView.utils'
+
+interface DetailItemProps {
+ label: string
+ children: React.ReactNode
+ ddClassName?: string
+}
+
+const DetailItem = ({ label, children, ddClassName = 'text-sm' }: DetailItemProps) => (
+
+
{label}
+ {children}
+
+)
+
+interface PlatformWebhooksEndpointDetailsProps {
+ deliverySearch: string
+ filteredDeliveries: WebhookDelivery[]
+ selectedEndpoint: WebhookEndpoint
+ onDeliverySearchChange: (value: string) => void
+ onOpenDelivery: (deliveryId: string) => void
+}
+
+export const PlatformWebhooksEndpointDetails = ({
+ deliverySearch,
+ filteredDeliveries,
+ selectedEndpoint,
+ onDeliverySearchChange,
+ onOpenDelivery,
+}: PlatformWebhooksEndpointDetailsProps) => {
+ const hasCustomHeaders = selectedEndpoint.customHeaders.length > 0
+
+ return (
+
+
+
Overview
+
+
+
+
+ {selectedEndpoint.url}
+
+
+ {selectedEndpoint.description || '-'}
+
+
+ {(selectedEndpoint.eventTypes.includes('*')
+ ? ['All events (*)']
+ : selectedEndpoint.eventTypes
+ ).map((eventType) => (
+
+ {eventType}
+
+ ))}
+
+
+ {hasCustomHeaders && (
+
+
+ {selectedEndpoint.customHeaders.map((header) => (
+
+ {header.key}:
+ {header.value}
+
+ ))}
+
+
+ )}
+
+ {selectedEndpoint.createdBy}
+
+
+
+
+
+
+
+
+
+
+
Deliveries
+
+
}
+ value={deliverySearch}
+ className="w-full lg:w-52"
+ onChange={(event) => onDeliverySearchChange(event.target.value)}
+ />
+
{filteredDeliveries.length} total
+
+
+
+
+
+ Status
+ Event type
+ Response
+ Attempted
+
+
+
+ {filteredDeliveries.length > 0 ? (
+ filteredDeliveries.map((delivery) => (
+ onOpenDelivery(delivery.id)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault()
+ onOpenDelivery(delivery.id)
+ }
+ }}
+ tabIndex={0}
+ >
+
+ {delivery.status}
+
+
+ {delivery.eventType}
+
+
+ {delivery.responseCode ? (
+
+ ) : (
+ –
+ )}
+
+
+
+
+
+ ))
+ ) : (
+
+ No deliveries found
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointList.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointList.tsx
new file mode 100644
index 00000000000..41015c161ef
--- /dev/null
+++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointList.tsx
@@ -0,0 +1,232 @@
+import { ChevronRight, Eye, MoreVertical, Plus, Search, Trash2 } from 'lucide-react'
+import { useRouter } from 'next/router'
+import { useMemo, useState } from 'react'
+
+import { createNavigationHandler } from 'lib/navigation'
+import {
+ Badge,
+ Button,
+ Card,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeadSort,
+ TableHeader,
+ TableRow,
+} from 'ui'
+import { EmptyStatePresentational, TimestampInfo } from 'ui-patterns'
+import { Input } from 'ui-patterns/DataInputs/Input'
+import type { WebhookEndpoint } from './PlatformWebhooks.types'
+
+interface PlatformWebhooksEndpointListProps {
+ filteredEndpoints: WebhookEndpoint[]
+ search: string
+ webhooksHref: string
+ onCreateEndpoint: () => void
+ onDeleteEndpoint: (endpointId: string) => void
+ onSearchChange: (value: string) => void
+ onViewEndpoint: (endpointId: string) => void
+}
+
+export const PlatformWebhooksEndpointList = ({
+ filteredEndpoints,
+ search,
+ webhooksHref,
+ onCreateEndpoint,
+ onDeleteEndpoint,
+ onSearchChange,
+ onViewEndpoint,
+}: PlatformWebhooksEndpointListProps) => {
+ const router = useRouter()
+ const formatEventCount = (eventTypes: string[]) => {
+ if (eventTypes.includes('*')) return 'All events'
+ if (eventTypes.length === 1) return '1 event'
+ return `${eventTypes.length} events`
+ }
+ const [sort, setSort] = useState<'status:asc' | 'status:desc' | 'created:asc' | 'created:desc'>(
+ 'created:desc'
+ )
+
+ const [sortColumn, sortDirection] = sort.split(':') as ['status' | 'created', 'asc' | 'desc']
+
+ const getAriaSort = (column: 'status' | 'created') => {
+ if (sortColumn !== column) return 'none'
+ return sortDirection === 'asc' ? 'ascending' : 'descending'
+ }
+
+ const handleSortChange = (column: 'status' | 'created') => {
+ if (sortColumn !== column) {
+ setSort(`${column}:asc`)
+ return
+ }
+
+ setSort(`${column}:${sortDirection === 'asc' ? 'desc' : 'asc'}`)
+ }
+
+ const sortedEndpoints = useMemo(() => {
+ const items = [...filteredEndpoints]
+
+ items.sort((a, b) => {
+ if (sortColumn === 'status') {
+ const statusA = a.enabled ? 'enabled' : 'disabled'
+ const statusB = b.enabled ? 'enabled' : 'disabled'
+ const comparison = statusA.localeCompare(statusB)
+
+ if (comparison !== 0) return sortDirection === 'asc' ? comparison : -comparison
+ }
+
+ const createdComparison = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
+ return sortDirection === 'asc' ? createdComparison : -createdComparison
+ })
+
+ return items
+ }, [filteredEndpoints, sortColumn, sortDirection])
+
+ return (
+
+
+
Endpoints
+
+
+ }
+ value={search}
+ className="w-full lg:w-52"
+ onChange={(event) => onSearchChange(event.target.value)}
+ />
+ } onClick={onCreateEndpoint}>
+ New endpoint
+
+
+
+ {filteredEndpoints.length === 0 ? (
+
+
+
+ ) : (
+
+
+
+
+
+
+ Status
+
+
+ URL
+ Events
+
+
+ Created
+
+
+
+
+
+
+ {sortedEndpoints.map((endpoint) => (
+
+
+
+ {endpoint.enabled ? 'Enabled' : 'Disabled'}
+
+
+
+ {endpoint.url}
+ {endpoint.description && (
+
+ {endpoint.description}
+
+ )}
+
+
+ {formatEventCount(endpoint.eventTypes)}
+
+
+
+
+ by {endpoint.createdBy}
+
+
+
+ event.stopPropagation()}
+ onKeyDown={(event) => event.stopPropagation()}
+ >
+
+
+ } className="w-7" />
+
+
+ {
+ onViewEndpoint(endpoint.id)
+ }}
+ >
+
+ View details
+
+
+ onDeleteEndpoint(endpoint.id)}
+ >
+
+ Delete
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx
new file mode 100644
index 00000000000..42bd837657f
--- /dev/null
+++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx
@@ -0,0 +1,581 @@
+import { zodResolver } from '@hookform/resolvers/zod'
+import { InlineLink } from 'components/ui/InlineLink'
+import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
+import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose'
+import { ChevronDown, Trash2 } from 'lucide-react'
+import { useEffect, useMemo, useState } from 'react'
+import { useFieldArray, useForm } from 'react-hook-form'
+import {
+ Accordion_Shadcn_ as Accordion,
+ AccordionContent_Shadcn_ as AccordionContent,
+ AccordionItem_Shadcn_ as AccordionItem,
+ AccordionTrigger_Shadcn_ as AccordionTrigger,
+ Button,
+ Checkbox_Shadcn_ as Checkbox,
+ cn,
+ Form_Shadcn_,
+ FormControl_Shadcn_,
+ FormField_Shadcn_,
+ Input_Shadcn_ as InputField,
+ Label_Shadcn_ as Label,
+ Separator,
+ Sheet,
+ SheetContent,
+ SheetFooter,
+ SheetHeader,
+ SheetSection,
+ SheetTitle,
+ Switch,
+ TextArea_Shadcn_ as Textarea,
+} from 'ui'
+import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
+import * as z from 'zod'
+
+import type {
+ UpsertWebhookEndpointInput,
+ WebhookEndpoint,
+ WebhookScope,
+} from './PlatformWebhooks.types'
+
+const endpointFormSchema = z
+ .object({
+ url: z.string().trim().url('Please enter a valid URL'),
+ description: z.string().trim().max(512, 'Description cannot exceed 512 characters'),
+ enabled: z.boolean().default(true),
+ subscribeAll: z.boolean().default(false),
+ eventTypes: z.array(z.string()).default([]),
+ customHeaders: z
+ .array(
+ z.object({
+ key: z.string().trim(),
+ value: z.string().trim(),
+ })
+ )
+ .default([]),
+ })
+ .superRefine((data, ctx) => {
+ if (!data.subscribeAll && data.eventTypes.length === 0) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'Select at least one event type',
+ path: ['eventTypes'],
+ })
+ }
+ })
+
+export type EndpointFormValues = z.infer
+
+const toEventTypes = (values: EndpointFormValues) =>
+ values.subscribeAll ? ['*'] : values.eventTypes
+
+type EventTypeGroup = {
+ id: string
+ label: string
+ eventTypes: string[]
+}
+
+const buildEventTypeGroups = (scope: WebhookScope, eventTypes: string[]): EventTypeGroup[] => {
+ if (scope === 'project') {
+ return [{ id: 'project', label: 'Project events', eventTypes }]
+ }
+
+ const organizationEvents = eventTypes.filter((eventType) => eventType.startsWith('organization.'))
+ const projectEvents = eventTypes.filter((eventType) => eventType.startsWith('project.'))
+ const ungroupedEvents = eventTypes.filter(
+ (eventType) => !eventType.startsWith('organization.') && !eventType.startsWith('project.')
+ )
+
+ return [
+ { id: 'organization', label: 'Organization events', eventTypes: organizationEvents },
+ { id: 'project', label: 'Project events', eventTypes: projectEvents },
+ { id: 'other', label: 'Other events', eventTypes: ungroupedEvents },
+ ].filter((group) => group.eventTypes.length > 0)
+}
+
+const toggleEventType = (selectedEventTypes: string[], eventType: string, checked: boolean) => {
+ if (checked) return [...new Set([...selectedEventTypes, eventType])]
+ return selectedEventTypes.filter((value) => value !== eventType)
+}
+
+const toggleEventTypeGroup = (
+ selectedEventTypes: string[],
+ groupedEventTypes: string[],
+ checked: boolean
+) => {
+ if (checked) return [...new Set([...selectedEventTypes, ...groupedEventTypes])]
+ return selectedEventTypes.filter((value) => !groupedEventTypes.includes(value))
+}
+
+const toControlId = (prefix: string, value: string) =>
+ `${prefix}-${value.replace(/[^a-zA-Z0-9_-]/g, '-')}`
+
+export const toEndpointPayload = (values: EndpointFormValues): UpsertWebhookEndpointInput => ({
+ name: '',
+ url: values.url,
+ description: values.description,
+ enabled: values.enabled,
+ eventTypes: toEventTypes(values),
+ customHeaders: values.customHeaders,
+})
+
+interface EndpointSheetProps {
+ visible: boolean
+ mode: 'create' | 'edit'
+ scope: WebhookScope
+ orgSlug?: string
+ endpoint?: WebhookEndpoint
+ enabledOverride?: boolean | null
+ eventTypes: string[]
+ onClose: () => void
+ onSubmit: (values: EndpointFormValues) => void
+}
+
+export const PlatformWebhooksEndpointSheet = ({
+ visible,
+ mode,
+ scope,
+ orgSlug,
+ endpoint,
+ enabledOverride,
+ eventTypes,
+ onClose,
+ onSubmit,
+}: EndpointSheetProps) => {
+ const form = useForm({
+ resolver: zodResolver(endpointFormSchema),
+ defaultValues: {
+ url: '',
+ description: '',
+ enabled: true,
+ subscribeAll: false,
+ eventTypes: [],
+ customHeaders: [],
+ },
+ })
+ const isDirty = form.formState.isDirty
+ const {
+ confirmOnClose,
+ handleOpenChange,
+ modalProps: discardChangesModalProps,
+ } = useConfirmOnClose({
+ checkIsDirty: () => isDirty,
+ onClose,
+ })
+
+ const { fields, append, remove } = useFieldArray({
+ control: form.control,
+ name: 'customHeaders',
+ })
+
+ const subscribeAll = form.watch('subscribeAll')
+ const selectedEventTypes = form.watch('eventTypes')
+ const groupedEventTypes = useMemo(
+ () => buildEventTypeGroups(scope, eventTypes),
+ [scope, eventTypes]
+ )
+ const [openEventGroups, setOpenEventGroups] = useState([])
+
+ useEffect(() => {
+ if (!visible) return
+
+ if (!endpoint) {
+ form.reset({
+ url: '',
+ description: '',
+ enabled: true,
+ subscribeAll: false,
+ eventTypes: [],
+ customHeaders: [],
+ })
+ return
+ }
+
+ form.reset({
+ url: endpoint.url,
+ description: endpoint.description,
+ enabled: enabledOverride ?? endpoint.enabled,
+ subscribeAll: endpoint.eventTypes.includes('*'),
+ eventTypes: endpoint.eventTypes.includes('*') ? eventTypes : endpoint.eventTypes,
+ customHeaders: endpoint.customHeaders.map((header) => ({
+ key: header.key,
+ value: header.value,
+ })),
+ })
+ }, [enabledOverride, endpoint, eventTypes, form, visible])
+
+ useEffect(() => {
+ if (!visible) return
+ setOpenEventGroups(groupedEventTypes.map((group) => group.id))
+ }, [groupedEventTypes, visible])
+
+ useEffect(() => {
+ if (!visible) return
+ const allSelected =
+ eventTypes.length > 0 &&
+ eventTypes.every((eventType) => selectedEventTypes.includes(eventType))
+
+ if (subscribeAll !== allSelected) {
+ form.setValue('subscribeAll', allSelected, {
+ shouldDirty: true,
+ shouldValidate: true,
+ })
+ }
+ }, [eventTypes, form, selectedEventTypes, subscribeAll, visible])
+
+ return (
+
+
+
+ {mode === 'create' ? 'Create endpoint' : 'Edit endpoint'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksHeader.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksHeader.tsx
new file mode 100644
index 00000000000..6b34ea2bbc4
--- /dev/null
+++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksHeader.tsx
@@ -0,0 +1,91 @@
+import Link from 'next/link'
+import { ReactNode } from 'react'
+
+import { DocsButton } from 'components/ui/DocsButton'
+import {
+ Badge,
+ BreadcrumbItem_Shadcn_ as BreadcrumbItem,
+ BreadcrumbLink_Shadcn_ as BreadcrumbLink,
+ BreadcrumbList_Shadcn_ as BreadcrumbList,
+ BreadcrumbPage_Shadcn_ as BreadcrumbPage,
+ BreadcrumbSeparator_Shadcn_ as BreadcrumbSeparator,
+ Button,
+} from 'ui'
+import {
+ PageHeader,
+ PageHeaderAside,
+ PageHeaderBreadcrumb,
+ PageHeaderDescription,
+ PageHeaderMeta,
+ PageHeaderSummary,
+ PageHeaderTitle,
+} from 'ui-patterns/PageHeader'
+
+interface PlatformWebhooksHeaderProps {
+ hasSelectedEndpoint: boolean
+ headerTitle: string
+ headerDescription: string
+ endpointStatus?: 'enabled' | 'disabled'
+ endpointActions?: ReactNode
+ webhooksHref: string
+ scopeLabel: string
+}
+
+export const PlatformWebhooksHeader = ({
+ hasSelectedEndpoint,
+ headerTitle,
+ headerDescription,
+ endpointStatus,
+ endpointActions,
+ webhooksHref,
+ scopeLabel,
+}: PlatformWebhooksHeaderProps) => {
+ return (
+
+ {hasSelectedEndpoint && (
+
+
+
+
+ {scopeLabel}
+
+
+
+
+ Endpoint
+
+
+
+ )}
+
+
+
+
+ {headerTitle}
+ {hasSelectedEndpoint && endpointStatus && (
+
+ {endpointStatus === 'enabled' ? 'Enabled' : 'Disabled'}
+
+ )}
+
+
+ {headerDescription}
+
+
+ {hasSelectedEndpoint ? (
+ endpointActions
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.tsx
new file mode 100644
index 00000000000..c8104faa3b5
--- /dev/null
+++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.tsx
@@ -0,0 +1,483 @@
+import { useRouter } from 'next/router'
+import { parseAsString, parseAsStringLiteral, useQueryState } from 'nuqs'
+import { useEffect, useMemo, useState } from 'react'
+import { toast } from 'sonner'
+import { EllipsisVertical, Pencil, RotateCw, Trash2 } from 'lucide-react'
+
+import { useParams } from 'common'
+import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
+import { InlineLink } from 'components/ui/InlineLink'
+import { Admonition } from 'ui-patterns'
+import { useIsPlatformWebhooksEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ Button,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ Label_Shadcn_,
+ copyToClipboard,
+} from 'ui'
+import { PageContainer } from 'ui-patterns/PageContainer'
+import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
+import { Input } from 'ui-patterns/DataInputs/Input'
+import { PLATFORM_WEBHOOKS_MOCK_DATA } from './PlatformWebhooks.mock'
+import { PlatformWebhooksDeliveryDetailsSheet } from './PlatformWebhooksDeliveryDetailsSheet'
+import { PlatformWebhooksEndpointDetails } from './PlatformWebhooksEndpointDetails'
+import { PlatformWebhooksEndpointList } from './PlatformWebhooksEndpointList'
+import {
+ EndpointFormValues,
+ PlatformWebhooksEndpointSheet,
+ toEndpointPayload,
+} from './PlatformWebhooksEndpointSheet'
+import {
+ clearPendingSigningSecretReveal,
+ getPendingSigningSecretReveal,
+ setPendingSigningSecretReveal,
+ shouldHandleEndpointNotFound,
+} from './PlatformWebhooksPage.utils'
+import { PlatformWebhooksHeader } from './PlatformWebhooksHeader'
+import {
+ filterWebhookDeliveries,
+ filterWebhookEndpoints,
+ usePlatformWebhooksMockStore,
+} from './PlatformWebhooks.store'
+import type { WebhookScope } from './PlatformWebhooks.types'
+
+const PANEL_VALUES = ['create', 'edit'] as const
+
+interface PlatformWebhooksPageProps {
+ scope: WebhookScope
+ endpointId?: string
+}
+
+export const PlatformWebhooksPage = ({ scope, endpointId }: PlatformWebhooksPageProps) => {
+ const router = useRouter()
+ const { slug, ref } = useParams()
+ const { data: selectedOrganization } = useSelectedOrganizationQuery({
+ enabled: scope === 'project',
+ })
+ const platformWebhooksEnabled = useIsPlatformWebhooksEnabled()
+ const {
+ endpoints,
+ deliveries,
+ createEndpoint,
+ updateEndpoint,
+ deleteEndpoint,
+ regenerateSecret,
+ } = usePlatformWebhooksMockStore(scope)
+ const [deliveryId, setDeliveryId] = useQueryState('deliveryId', parseAsString)
+ const [panel, setPanel] = useQueryState('panel', parseAsStringLiteral(PANEL_VALUES))
+ const [search, setSearch] = useQueryState(
+ 'search',
+ parseAsString.withDefault('').withOptions({ history: 'replace', clearOnDefault: true })
+ )
+ const [deliverySearch, setDeliverySearch] = useQueryState(
+ 'deliverySearch',
+ parseAsString.withDefault('').withOptions({ history: 'replace', clearOnDefault: true })
+ )
+
+ const [endpointIdPendingDelete, setEndpointIdPendingDelete] = useState(null)
+ const [signingSecretReveal, setSigningSecretReveal] = useState<{ signingSecret: string } | null>(
+ null
+ )
+ const [showRegenerateSecretConfirm, setShowRegenerateSecretConfirm] = useState(false)
+ const [editEnabledOverride, setEditEnabledOverride] = useState(null)
+ const [deliveryDetailsTab, setDeliveryDetailsTab] = useState<'event' | 'response'>('event')
+ const [pendingCreatedEndpointId, setPendingCreatedEndpointId] = useState(null)
+
+ const scopeLabel = scope === 'organization' ? 'Organization Webhooks' : 'Project Webhooks'
+ const scopeDescription =
+ scope === 'organization'
+ ? 'Organization-level webhook endpoints and deliveries'
+ : 'Webhook endpoints specific to this project'
+ const fallbackHref =
+ scope === 'organization' ? `/org/${slug}/general` : `/project/${ref}/settings/general`
+
+ const eventTypeOptions = PLATFORM_WEBHOOKS_MOCK_DATA[scope].eventTypes
+ const webhooksHref =
+ scope === 'organization' ? `/org/${slug}/webhooks` : `/project/${ref}/settings/webhooks`
+
+ const selectedEndpoint = useMemo(
+ () => endpoints.find((endpoint) => endpoint.id === endpointId) ?? null,
+ [endpoints, endpointId]
+ )
+ const isEndpointView = !!selectedEndpoint
+ const headerTitle = isEndpointView ? 'Endpoint' : scopeLabel
+ const headerDescription = isEndpointView ? selectedEndpoint?.url ?? '' : scopeDescription
+
+ const endpointPendingDelete = useMemo(
+ () => endpoints.find((endpoint) => endpoint.id === endpointIdPendingDelete) ?? null,
+ [endpoints, endpointIdPendingDelete]
+ )
+
+ useEffect(() => {
+ if (!platformWebhooksEnabled) {
+ router.replace(fallbackHref)
+ }
+ }, [fallbackHref, platformWebhooksEnabled, router])
+
+ useEffect(() => {
+ if (
+ shouldHandleEndpointNotFound({
+ endpointId,
+ hasSelectedEndpoint: !!selectedEndpoint,
+ pendingCreatedEndpointId,
+ })
+ ) {
+ toast('Endpoint not found')
+ router.replace(webhooksHref)
+ }
+ }, [endpointId, pendingCreatedEndpointId, selectedEndpoint, router, webhooksHref])
+
+ useEffect(() => {
+ if (!pendingCreatedEndpointId) return
+ if (
+ endpointId !== pendingCreatedEndpointId ||
+ selectedEndpoint?.id === pendingCreatedEndpointId
+ ) {
+ setPendingCreatedEndpointId(null)
+ }
+ }, [endpointId, pendingCreatedEndpointId, selectedEndpoint])
+
+ useEffect(() => {
+ if (signingSecretReveal || !endpointId) return
+ const pendingReveal = getPendingSigningSecretReveal(scope, endpointId)
+ if (!pendingReveal) return
+ setSigningSecretReveal({ signingSecret: pendingReveal.signingSecret })
+ }, [endpointId, scope, signingSecretReveal])
+
+ const filteredEndpoints = useMemo(() => {
+ return filterWebhookEndpoints(endpoints, search)
+ }, [endpoints, search])
+
+ const filteredDeliveries = useMemo(() => {
+ if (!selectedEndpoint) return []
+ return filterWebhookDeliveries(deliveries, selectedEndpoint.id, deliverySearch)
+ }, [deliveries, deliverySearch, selectedEndpoint])
+
+ const selectedDelivery = useMemo(() => {
+ if (!selectedEndpoint || !deliveryId) return null
+ return (
+ deliveries.find(
+ (delivery) => delivery.id === deliveryId && delivery.endpointId === selectedEndpoint.id
+ ) ?? null
+ )
+ }, [deliveries, deliveryId, selectedEndpoint])
+
+ const deliveryAttempt = useMemo(() => {
+ if (!selectedEndpoint || !selectedDelivery) return null
+ const endpointDeliveries = deliveries
+ .filter((delivery) => delivery.endpointId === selectedEndpoint.id)
+ .sort((a, b) => new Date(b.attemptAt).getTime() - new Date(a.attemptAt).getTime())
+ const index = endpointDeliveries.findIndex((delivery) => delivery.id === selectedDelivery.id)
+ return index >= 0 ? index + 1 : null
+ }, [deliveries, selectedDelivery, selectedEndpoint])
+
+ const deliveryEventPayload = useMemo(() => {
+ if (!selectedEndpoint || !selectedDelivery) return ''
+ return JSON.stringify(
+ {
+ endpoint_id: selectedEndpoint.id,
+ endpoint_url: selectedEndpoint.url,
+ event_type: selectedDelivery.eventType,
+ event_id: selectedDelivery.id,
+ attempted_at: selectedDelivery.attemptAt,
+ scope,
+ },
+ null,
+ 2
+ )
+ }, [scope, selectedDelivery, selectedEndpoint])
+
+ const deliveryResponsePayload = useMemo(() => {
+ if (!selectedEndpoint || !selectedDelivery) return ''
+ return JSON.stringify(
+ {
+ endpoint_id: selectedEndpoint.id,
+ delivery_id: selectedDelivery.id,
+ status: selectedDelivery.status,
+ response_code: selectedDelivery.responseCode ?? null,
+ },
+ null,
+ 2
+ )
+ }, [selectedDelivery, selectedEndpoint])
+
+ const handleDeleteEndpoint = () => {
+ if (!endpointPendingDelete) return
+ deleteEndpoint(endpointPendingDelete.id)
+ if (endpointPendingDelete.id === endpointId) {
+ router.push(webhooksHref)
+ setDeliverySearch('')
+ }
+ setEndpointIdPendingDelete(null)
+ toast.success(`Deleted endpoint "${endpointPendingDelete.url}"`)
+ }
+
+ const handleUpsertEndpoint = (values: EndpointFormValues) => {
+ if (panel === 'create') {
+ const { endpointId: createdEndpointId, signingSecret } = createEndpoint(
+ toEndpointPayload(values)
+ )
+ setPendingCreatedEndpointId(createdEndpointId)
+ setPendingSigningSecretReveal(scope, {
+ endpointId: createdEndpointId,
+ signingSecret,
+ })
+ router.push(`${webhooksHref}/${encodeURIComponent(createdEndpointId)}`)
+ setSigningSecretReveal({ signingSecret })
+ setPanel(null)
+ setEditEnabledOverride(null)
+ toast.success('Endpoint created')
+ return
+ }
+
+ if (panel === 'edit' && selectedEndpoint) {
+ updateEndpoint(selectedEndpoint.id, toEndpointPayload(values))
+ setPanel(null)
+ setEditEnabledOverride(null)
+ toast.success('Endpoint updated')
+ }
+ }
+
+ const handleRegenerateSecret = () => {
+ if (!selectedEndpoint) return
+ const nextSecret = regenerateSecret(selectedEndpoint.id)
+ if (!nextSecret) return
+ setSigningSecretReveal({ signingSecret: nextSecret })
+ setShowRegenerateSecretConfirm(false)
+ toast.success('Signing secret regenerated')
+ }
+
+ const handleCopy = (value: string, label: string) => {
+ copyToClipboard(value)
+ toast.success(`Copied ${label}`)
+ }
+
+ const isEndpointSheetOpen = panel === 'create' || (panel === 'edit' && !!selectedEndpoint)
+
+ useEffect(() => {
+ if (!selectedEndpoint && !!deliveryId) {
+ setDeliveryId(null)
+ }
+ }, [deliveryId, selectedEndpoint, setDeliveryId])
+
+ useEffect(() => {
+ if (!!deliveryId && !selectedDelivery) {
+ setDeliveryId(null)
+ }
+ }, [deliveryId, selectedDelivery, setDeliveryId])
+
+ if (!platformWebhooksEnabled) {
+ return null
+ }
+
+ return (
+ <>
+
+ }
+ onClick={() => {
+ setEditEnabledOverride(null)
+ setPanel('edit')
+ }}
+ >
+ Edit
+
+
+
+ } className="w-7" />
+
+
+ setShowRegenerateSecretConfirm(true)}
+ >
+
+ Regenerate secret
+
+ setEndpointIdPendingDelete(selectedEndpoint.id)}
+ >
+
+ Delete endpoint
+
+
+
+ >
+ ) : undefined
+ }
+ webhooksHref={webhooksHref}
+ scopeLabel={scopeLabel}
+ />
+
+
+
+
+ {!selectedEndpoint ? (
+ setPanel('create')}
+ onDeleteEndpoint={(id) => setEndpointIdPendingDelete(id)}
+ onSearchChange={setSearch}
+ onViewEndpoint={(id) => {
+ router.push(`${webhooksHref}/${encodeURIComponent(id)}`)
+ setPanel(null)
+ }}
+ />
+ ) : (
+ {
+ setDeliveryDetailsTab('event')
+ setDeliveryId(id)
+ }}
+ />
+ )}
+
+
+
+
+ !open && setDeliveryId(null)}
+ onTabChange={setDeliveryDetailsTab}
+ />
+
+ {
+ setPanel(null)
+ setEditEnabledOverride(null)
+ }}
+ onSubmit={handleUpsertEndpoint}
+ />
+
+ !open && setEndpointIdPendingDelete(null)}
+ >
+
+
+ Delete endpoint
+
+ {endpointPendingDelete
+ ? `This will delete endpoint ${endpointPendingDelete.url}. This action cannot be undone.`
+ : 'This action cannot be undone.'}
+
+
+
+ Cancel
+
+ Delete endpoint
+
+
+
+
+
+
+
+
+ Regenerate secret
+
+ This will rotate the current signing secret used for webhook signature verification.
+
+
+
+ Cancel
+
+ Regenerate
+
+
+
+
+
+ {
+ if (open) return
+ setSigningSecretReveal(null)
+ clearPendingSigningSecretReveal(scope)
+ }}
+ >
+
+
+ Signing secret
+
+ Use this secret to verify webhook signatures using the{' '}
+ Standard Webhooks{' '}
+ specification.
+
+
+ {/* Content */}
+
+
+ Signing secret
+ {}}
+ onCopy={() => toast.success('Copied signing secret')}
+ />
+
+
+
+
+ {
+ setSigningSecretReveal(null)
+ clearPendingSigningSecretReveal(scope)
+ }}
+ >
+ I’ve stored the secret
+
+
+
+
+ >
+ )
+}
diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.utils.test.ts b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.utils.test.ts
new file mode 100644
index 00000000000..acdf48259f9
--- /dev/null
+++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.utils.test.ts
@@ -0,0 +1,87 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+
+import {
+ clearPendingSigningSecretReveal,
+ getPendingSigningSecretReveal,
+ resetPendingSigningSecretRevealForTests,
+ setPendingSigningSecretReveal,
+ shouldHandleEndpointNotFound,
+} from './PlatformWebhooksPage.utils'
+
+describe('PlatformWebhooksPage.utils', () => {
+ beforeEach(() => {
+ resetPendingSigningSecretRevealForTests()
+ })
+
+ it('returns false when endpoint id is absent', () => {
+ expect(
+ shouldHandleEndpointNotFound({
+ endpointId: undefined,
+ hasSelectedEndpoint: false,
+ pendingCreatedEndpointId: null,
+ })
+ ).toBe(false)
+ })
+
+ it('returns false when endpoint is selected', () => {
+ expect(
+ shouldHandleEndpointNotFound({
+ endpointId: 'endpoint-abc12345',
+ hasSelectedEndpoint: true,
+ pendingCreatedEndpointId: null,
+ })
+ ).toBe(false)
+ })
+
+ it('returns false while route is transitioning to a newly created endpoint', () => {
+ expect(
+ shouldHandleEndpointNotFound({
+ endpointId: 'endpoint-abc12345',
+ hasSelectedEndpoint: false,
+ pendingCreatedEndpointId: 'endpoint-abc12345',
+ })
+ ).toBe(false)
+ })
+
+ it('returns true for invalid endpoint routes', () => {
+ expect(
+ shouldHandleEndpointNotFound({
+ endpointId: 'endpoint-missing',
+ hasSelectedEndpoint: false,
+ pendingCreatedEndpointId: null,
+ })
+ ).toBe(true)
+ })
+
+ it('stores and reads pending signing secret reveal for matching endpoint route', () => {
+ setPendingSigningSecretReveal('project', {
+ endpointId: 'endpoint-abc12345',
+ signingSecret: 'whsec_example',
+ })
+
+ expect(getPendingSigningSecretReveal('project', 'endpoint-abc12345')).toEqual({
+ endpointId: 'endpoint-abc12345',
+ signingSecret: 'whsec_example',
+ })
+ })
+
+ it('ignores pending signing secret reveal when endpoint route does not match', () => {
+ setPendingSigningSecretReveal('project', {
+ endpointId: 'endpoint-abc12345',
+ signingSecret: 'whsec_example',
+ })
+
+ expect(getPendingSigningSecretReveal('project', 'endpoint-other')).toBeNull()
+ })
+
+ it('clears pending signing secret reveal', () => {
+ setPendingSigningSecretReveal('project', {
+ endpointId: 'endpoint-abc12345',
+ signingSecret: 'whsec_example',
+ })
+
+ clearPendingSigningSecretReveal('project')
+
+ expect(getPendingSigningSecretReveal('project', 'endpoint-abc12345')).toBeNull()
+ })
+})
diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.utils.ts b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.utils.ts
new file mode 100644
index 00000000000..7979419afeb
--- /dev/null
+++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.utils.ts
@@ -0,0 +1,47 @@
+import type { WebhookScope } from './PlatformWebhooks.types'
+
+export const shouldHandleEndpointNotFound = ({
+ endpointId,
+ hasSelectedEndpoint,
+ pendingCreatedEndpointId,
+}: {
+ endpointId?: string
+ hasSelectedEndpoint: boolean
+ pendingCreatedEndpointId: string | null
+}) => {
+ if (!endpointId) return false
+ if (hasSelectedEndpoint) return false
+ return endpointId !== pendingCreatedEndpointId
+}
+
+interface PendingSigningSecretReveal {
+ endpointId: string
+ signingSecret: string
+}
+
+const pendingSigningSecretRevealByScope: Partial> =
+ {}
+
+export const setPendingSigningSecretReveal = (
+ scope: WebhookScope,
+ value: PendingSigningSecretReveal
+) => {
+ pendingSigningSecretRevealByScope[scope] = value
+}
+
+export const getPendingSigningSecretReveal = (scope: WebhookScope, endpointId?: string) => {
+ const pending = pendingSigningSecretRevealByScope[scope]
+ if (!pending) return null
+ if (endpointId && pending.endpointId !== endpointId) return null
+ return pending
+}
+
+export const clearPendingSigningSecretReveal = (scope: WebhookScope) => {
+ delete pendingSigningSecretRevealByScope[scope]
+}
+
+export const resetPendingSigningSecretRevealForTests = () => {
+ for (const key of Object.keys(pendingSigningSecretRevealByScope) as WebhookScope[]) {
+ delete pendingSigningSecretRevealByScope[key]
+ }
+}
diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksView.utils.ts b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksView.utils.ts
new file mode 100644
index 00000000000..37a7eef0ded
--- /dev/null
+++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksView.utils.ts
@@ -0,0 +1,31 @@
+import type { WebhookDeliveryStatus } from './PlatformWebhooks.types'
+
+export const statusBadgeVariant: Record<
+ WebhookDeliveryStatus,
+ 'default' | 'success' | 'destructive'
+> = {
+ pending: 'default',
+ success: 'success',
+ failure: 'destructive',
+ skipped: 'default',
+}
+
+export const formatDate = (value: string) =>
+ new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(
+ new Date(value)
+ )
+
+export const formatEvents = (eventTypes: string[]) =>
+ eventTypes.includes('*') ? 'All events (*)' : eventTypes.join(', ')
+
+export const formatDeliveryStatus = (status: WebhookDeliveryStatus) =>
+ `${status.charAt(0).toUpperCase()}${status.slice(1)}`
+
+export const responseCodeBadgeVariant = (
+ responseCode?: number
+): 'default' | 'success' | 'destructive' => {
+ if (!responseCode) return 'default'
+ if (responseCode >= 200 && responseCode < 300) return 'success'
+ if (responseCode >= 400) return 'destructive'
+ return 'default'
+}
diff --git a/apps/studio/components/interfaces/Platform/Webhooks/index.ts b/apps/studio/components/interfaces/Platform/Webhooks/index.ts
new file mode 100644
index 00000000000..ad1afa63c1d
--- /dev/null
+++ b/apps/studio/components/interfaces/Platform/Webhooks/index.ts
@@ -0,0 +1,11 @@
+export { PlatformWebhooksPage } from './PlatformWebhooksPage'
+export { usePlatformWebhooksMockStore } from './PlatformWebhooks.store'
+export type {
+ PlatformWebhooksState,
+ UpsertWebhookEndpointInput,
+ WebhookDelivery,
+ WebhookDeliveryStatus,
+ WebhookEndpoint,
+ WebhookHeader,
+ WebhookScope,
+} from './PlatformWebhooks.types'
diff --git a/apps/studio/components/interfaces/Sidebar.tsx b/apps/studio/components/interfaces/Sidebar.tsx
index ef2229ea81a..7b37b7926ad 100644
--- a/apps/studio/components/interfaces/Sidebar.tsx
+++ b/apps/studio/components/interfaces/Sidebar.tsx
@@ -45,6 +45,7 @@ import {
import { Route } from '../ui/ui.types'
import {
useIsAPIDocsSidePanelEnabled,
+ useIsPlatformWebhooksEnabled,
useUnifiedLogsPreview,
} from './App/FeaturePreview/FeaturePreviewContext'
@@ -225,6 +226,7 @@ const ProjectLinks = () => {
const { mutate: sendEvent } = useSendEventMutation()
const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled()
+ const platformWebhooksEnabled = useIsPlatformWebhooksEnabled()
const { isEnabled: isUnifiedLogsEnabled } = useUnifiedLogsPreview()
const activeRoute = router.pathname.split('/')[3]
@@ -256,7 +258,9 @@ const ProjectLinks = () => {
showReports,
apiDocsSidePanel: isNewAPIDocsEnabled,
})
- const settingsRoutes = generateSettingsRoutes(ref, project)
+ const settingsRoutes = generateSettingsRoutes(ref, project, {
+ platformWebhooks: platformWebhooksEnabled,
+ })
return (
diff --git a/apps/studio/components/layouts/DatabaseLayout/Database.Commands.tsx b/apps/studio/components/layouts/DatabaseLayout/Database.Commands.tsx
index 1ccb9a81ad2..53ba2cf8c98 100644
--- a/apps/studio/components/layouts/DatabaseLayout/Database.Commands.tsx
+++ b/apps/studio/components/layouts/DatabaseLayout/Database.Commands.tsx
@@ -89,13 +89,6 @@ export function useDatabaseGotoCommands(options?: CommandOptions) {
} as IRouteCommand,
]
: []),
- {
- id: 'nav-database-hooks',
- name: 'Webhooks',
- value: 'Database: Webhooks',
- route: `/project/${ref}/integrations/webhooks`,
- defaultHidden: true,
- },
{
id: 'nav-database-backups',
name: 'Backups',
diff --git a/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx b/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx
index e8a6a4bb1c0..c40b45a8ebf 100644
--- a/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx
+++ b/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx
@@ -93,7 +93,7 @@ export const useGenerateDatabaseMenu = (): ProductMenuGroup[] => {
rightIcon: ExternalLinkIcon,
},
pgNetExtensionExists && {
- name: 'Webhooks',
+ name: 'Database Webhooks',
key: 'hooks',
url: `/project/${ref}/integrations/webhooks/overview`,
rightIcon: ExternalLinkIcon,
diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx
index 30bebf679ab..9b7262cadf0 100644
--- a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx
+++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx
@@ -182,8 +182,14 @@ export const generateOtherRoutes = (
]
}
-export const generateSettingsRoutes = (ref?: string, project?: Project): Route[] => {
- const settingsMenu = generateSettingsMenu(ref as string)
+export const generateSettingsRoutes = (
+ ref?: string,
+ project?: Project,
+ features?: { platformWebhooks?: boolean }
+): Route[] => {
+ const settingsMenu = generateSettingsMenu(ref as string, undefined, undefined, {
+ platformWebhooks: features?.platformWebhooks,
+ })
return [
{
key: 'settings',
diff --git a/apps/studio/components/layouts/ProjectLayout/OrganizationSettingsLayout.test.ts b/apps/studio/components/layouts/ProjectLayout/OrganizationSettingsLayout.test.ts
index 23a4bfdf06c..6a2b4c0aa97 100644
--- a/apps/studio/components/layouts/ProjectLayout/OrganizationSettingsLayout.test.ts
+++ b/apps/studio/components/layouts/ProjectLayout/OrganizationSettingsLayout.test.ts
@@ -1,11 +1,26 @@
import { describe, expect, it } from 'vitest'
import {
+ generateOrganizationSettingsMenuItems,
generateOrganizationSettingsSections,
getOrganizationSettingsDocumentTitle,
normalizeOrganizationSettingsPath,
} from './OrganizationSettingsLayout'
+describe('generateOrganizationSettingsMenuItems', () => {
+ it('includes webhooks entry for organization settings nav', () => {
+ const items = generateOrganizationSettingsMenuItems({
+ slug: 'my-org',
+ showSecuritySettings: true,
+ showSsoSettings: true,
+ showLegalDocuments: true,
+ })
+
+ expect(items.some((item) => item.label === 'Webhooks')).toBe(true)
+ expect(items.some((item) => item.href === '/org/my-org/webhooks')).toBe(true)
+ })
+})
+
describe('OrganizationSettingsLayout helpers', () => {
it('returns expected organization settings sections and links', () => {
const sections = generateOrganizationSettingsSections({
@@ -26,6 +41,7 @@ describe('OrganizationSettingsLayout helpers', () => {
'Security',
'SSO',
'OAuth Apps',
+ 'Webhooks',
'Audit Logs',
'Legal Documents',
])
@@ -52,6 +68,7 @@ describe('OrganizationSettingsLayout helpers', () => {
expect(sections.flatMap((section) => section.links.map((item) => item.label))).toEqual([
'General',
'OAuth Apps',
+ 'Webhooks',
'Audit Logs',
])
})
@@ -72,6 +89,21 @@ describe('OrganizationSettingsLayout helpers', () => {
).toBe(true)
})
+ it('keeps webhooks nav item active for nested endpoint routes', () => {
+ const sections = generateOrganizationSettingsSections({
+ slug: 'my-org',
+ currentPath: '/org/my-org/webhooks/org-endpoint-1',
+ showSecuritySettings: true,
+ showSsoSettings: true,
+ showLegalDocuments: true,
+ })
+
+ expect(
+ sections.flatMap((section) => section.links).find((item) => item.label === 'Webhooks')
+ ?.isActive
+ ).toBe(true)
+ })
+
it('uses settings as default document title when page title is not provided', () => {
expect(getOrganizationSettingsDocumentTitle(undefined, 'Supabase')).toBe('Settings | Supabase')
})
diff --git a/apps/studio/components/layouts/ProjectLayout/OrganizationSettingsLayout.tsx b/apps/studio/components/layouts/ProjectLayout/OrganizationSettingsLayout.tsx
index a7c7aa10930..2671ab7e310 100644
--- a/apps/studio/components/layouts/ProjectLayout/OrganizationSettingsLayout.tsx
+++ b/apps/studio/components/layouts/ProjectLayout/OrganizationSettingsLayout.tsx
@@ -2,18 +2,23 @@ import Head from 'next/head'
import { PropsWithChildren } from 'react'
import { useParams } from 'common'
+import { useIsPlatformWebhooksEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import type { SidebarSection } from 'components/layouts/AccountLayout/AccountLayout.types'
import { WithSidebar } from 'components/layouts/AccountLayout/WithSidebar'
import { useCustomContent } from 'hooks/custom-content/useCustomContent'
import { useCurrentPath } from 'hooks/misc/useCurrentPath'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
-interface OrganizationSettingsSectionsProps {
+interface OrganizationSettingsMenuItemsProps {
slug?: string
- currentPath: string
showSecuritySettings?: boolean
showSsoSettings?: boolean
showLegalDocuments?: boolean
+ showPlatformWebhooks?: boolean
+}
+
+interface OrganizationSettingsSectionsProps extends OrganizationSettingsMenuItemsProps {
+ currentPath: string
}
interface OrganizationSettingsLayoutProps {
@@ -27,13 +32,79 @@ export const getOrganizationSettingsDocumentTitle = (
title: string
) => `${getOrganizationSettingsPageTitle(pageTitle)} | ${title}`
-export const generateOrganizationSettingsSections = ({
+export const generateOrganizationSettingsMenuItems = ({
slug,
- currentPath,
showSecuritySettings = true,
showSsoSettings = true,
showLegalDocuments = true,
+ showPlatformWebhooks = true,
+}: OrganizationSettingsMenuItemsProps) => [
+ {
+ key: 'general',
+ label: 'General',
+ href: `/org/${slug}/general`,
+ },
+ ...(showSecuritySettings
+ ? [
+ {
+ key: 'security',
+ label: 'Security',
+ href: `/org/${slug}/security`,
+ },
+ ]
+ : []),
+ {
+ key: 'apps',
+ label: 'OAuth Apps',
+ href: `/org/${slug}/apps`,
+ },
+ ...(showSsoSettings
+ ? [
+ {
+ key: 'sso',
+ label: 'SSO',
+ href: `/org/${slug}/sso`,
+ },
+ ]
+ : []),
+ ...(showPlatformWebhooks
+ ? [
+ {
+ key: 'webhooks',
+ label: 'Webhooks',
+ href: `/org/${slug}/webhooks`,
+ },
+ ]
+ : []),
+ {
+ key: 'audit',
+ label: 'Audit Logs',
+ href: `/org/${slug}/audit`,
+ },
+ ...(showLegalDocuments
+ ? [
+ {
+ key: 'documents',
+ label: 'Legal Documents',
+ href: `/org/${slug}/documents`,
+ },
+ ]
+ : []),
+]
+
+export const generateOrganizationSettingsSections = ({
+ currentPath,
+ slug,
+ showSecuritySettings = true,
+ showSsoSettings = true,
+ showLegalDocuments = true,
+ showPlatformWebhooks = true,
}: OrganizationSettingsSectionsProps): SidebarSection[] => {
+ const isLinkActive = (key: string, href: string) =>
+ key === 'webhooks'
+ ? currentPath === href || currentPath.startsWith(`${href}/`)
+ : currentPath === href
+
const configurationLinks = [
{
key: 'general',
@@ -66,6 +137,15 @@ export const generateOrganizationSettingsSections = ({
label: 'OAuth Apps',
href: `/org/${slug}/apps`,
},
+ ...(showPlatformWebhooks
+ ? [
+ {
+ key: 'webhooks',
+ label: 'Webhooks',
+ href: `/org/${slug}/webhooks`,
+ },
+ ]
+ : []),
]
const complianceLinks = [
@@ -91,7 +171,7 @@ export const generateOrganizationSettingsSections = ({
heading: 'Configuration',
links: configurationLinks.map((item) => ({
...item,
- isActive: currentPath === item.href,
+ isActive: isLinkActive(item.key, item.href),
})),
},
{
@@ -99,7 +179,7 @@ export const generateOrganizationSettingsSections = ({
heading: 'Connections',
links: connectionsLinks.map((item) => ({
...item,
- isActive: currentPath === item.href,
+ isActive: isLinkActive(item.key, item.href),
})),
},
{
@@ -107,7 +187,7 @@ export const generateOrganizationSettingsSections = ({
heading: 'Compliance',
links: complianceLinks.map((item) => ({
...item,
- isActive: currentPath === item.href,
+ isActive: isLinkActive(item.key, item.href),
})),
},
]
@@ -118,6 +198,7 @@ function OrganizationSettingsLayout({
pageTitle,
}: PropsWithChildren) {
const { slug } = useParams()
+ const showPlatformWebhooks = useIsPlatformWebhooksEnabled()
const fullCurrentPath = useCurrentPath()
const currentPath = normalizeOrganizationSettingsPath(fullCurrentPath)
const { appTitle } = useCustomContent(['app:title'])
@@ -134,11 +215,12 @@ function OrganizationSettingsLayout({
])
const sections = generateOrganizationSettingsSections({
- slug,
currentPath,
+ slug,
showSecuritySettings,
showSsoSettings,
showLegalDocuments,
+ showPlatformWebhooks,
})
return (
diff --git a/apps/studio/components/layouts/ProjectSettingsLayout/ProjectSettings.Commands.tsx b/apps/studio/components/layouts/ProjectSettingsLayout/ProjectSettings.Commands.tsx
index 0caaf7a425b..06980dd0218 100644
--- a/apps/studio/components/layouts/ProjectSettingsLayout/ProjectSettings.Commands.tsx
+++ b/apps/studio/components/layouts/ProjectSettingsLayout/ProjectSettings.Commands.tsx
@@ -1,4 +1,5 @@
import { useParams } from 'common'
+import { useIsPlatformWebhooksEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useRouter } from 'next/router'
import type { CommandOptions, ICommand } from 'ui-patterns/CommandMenu'
import { useRegisterCommands, useSetCommandMenuOpen } from 'ui-patterns/CommandMenu'
@@ -10,8 +11,10 @@ import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
export function useProjectSettingsGotoCommands(options?: CommandOptions) {
const router = useRouter()
const setIsOpen = useSetCommandMenuOpen()
- let { ref } = useParams()
+ let { ref, slug } = useParams()
+ const platformWebhooksEnabled = useIsPlatformWebhooksEnabled()
ref ||= '_'
+ const hasOrgSlug = typeof slug === 'string' && slug.length > 0 && slug !== '_'
const { projectSettingsLogDrains, projectSettingsCustomDomains, authenticationSignInProviders } =
useIsFeatureEnabled([
@@ -43,6 +46,26 @@ export function useProjectSettingsGotoCommands(options?: CommandOptions) {
: `/project/${ref}/auth/policies`,
defaultHidden: true,
},
+ ...(platformWebhooksEnabled
+ ? [
+ {
+ id: 'nav-project-settings-webhooks',
+ name: 'Project Webhooks',
+ route: `/project/${ref}/settings/webhooks`,
+ defaultHidden: true,
+ } as IRouteCommand,
+ ...(hasOrgSlug
+ ? [
+ {
+ id: 'nav-organization-settings-webhooks',
+ name: 'Organization Webhooks',
+ route: `/org/${slug}/webhooks`,
+ defaultHidden: true,
+ } as IRouteCommand,
+ ]
+ : []),
+ ]
+ : []),
{
id: 'nav-project-settings-api',
name: 'API Settings',
@@ -146,6 +169,6 @@ export function useProjectSettingsGotoCommands(options?: CommandOptions) {
]
: []),
],
- { ...options, deps: [ref] }
+ { ...options, deps: [platformWebhooksEnabled, ref, slug] }
)
}
diff --git a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsLayout.tsx b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsLayout.tsx
index 9b22d850b45..6d0feda1d88 100644
--- a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsLayout.tsx
+++ b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsLayout.tsx
@@ -2,6 +2,7 @@ import { useRouter } from 'next/router'
import { PropsWithChildren } from 'react'
import { useParams } from 'common'
+import { useIsPlatformWebhooksEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { ProductMenu } from 'components/ui/ProductMenu'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
@@ -19,6 +20,7 @@ const SettingsLayout = ({ title, children }: PropsWithChildren {
+ const actual = await vi.importActual>('lib/constants')
+ return {
+ ...actual,
+ IS_PLATFORM: true,
+ }
+})
+
+import { generateSettingsMenu } from './SettingsMenu.utils'
+
+describe('generateSettingsMenu', () => {
+ it('includes webhooks in project settings items', () => {
+ const menu = generateSettingsMenu(
+ 'project-ref',
+ { status: 'ACTIVE_HEALTHY' } as any,
+ { slug: 'my-org' } as any,
+ { platformWebhooks: true }
+ )
+
+ const configurationGroup = menu.find((group) => group.title === 'Configuration')
+ const hasWebhooks = configurationGroup?.items.some(
+ (item) => item.name === 'Webhooks' && item.url === '/project/project-ref/settings/webhooks'
+ )
+
+ expect(hasWebhooks).toBe(true)
+ })
+
+ it('hides webhooks when platformWebhooks feature is disabled', () => {
+ const menu = generateSettingsMenu(
+ 'project-ref',
+ { status: 'ACTIVE_HEALTHY' } as any,
+ { slug: 'my-org' } as any,
+ { platformWebhooks: false }
+ )
+
+ const configurationGroup = menu.find((group) => group.title === 'Configuration')
+ const hasWebhooks = configurationGroup?.items.some((item) => item.name === 'Webhooks')
+
+ expect(hasWebhooks).toBe(false)
+ })
+})
diff --git a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx
index f92c0bea154..7b6e2aec480 100644
--- a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx
+++ b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx
@@ -17,6 +17,7 @@ export const generateSettingsMenu = (
legacyJwtKeys?: boolean
logDrains?: boolean
billing?: boolean
+ platformWebhooks?: boolean
}
): ProductMenuGroup[] => {
if (!IS_PLATFORM) {
@@ -39,6 +40,7 @@ export const generateSettingsMenu = (
const legacyJwtKeysEnabled = features?.legacyJwtKeys ?? true
const billingEnabled = features?.billing ?? true
+ const platformWebhooksEnabled = features?.platformWebhooks ?? false
return [
{
@@ -72,6 +74,17 @@ export const generateSettingsMenu = (
items: [],
disabled: !isProjectActive,
},
+ ...(platformWebhooksEnabled
+ ? [
+ {
+ name: 'Webhooks',
+ key: 'webhooks',
+ url: `/project/${ref}/settings/webhooks`,
+ items: [],
+ disabled: !isProjectActive,
+ },
+ ]
+ : []),
{
name: 'API Keys',
diff --git a/apps/studio/pages/org/[slug]/webhooks/[endpointId].tsx b/apps/studio/pages/org/[slug]/webhooks/[endpointId].tsx
new file mode 100644
index 00000000000..1b782142f27
--- /dev/null
+++ b/apps/studio/pages/org/[slug]/webhooks/[endpointId].tsx
@@ -0,0 +1,24 @@
+import { useRouter } from 'next/router'
+
+import { PlatformWebhooksPage } from 'components/interfaces/Platform/Webhooks'
+import DefaultLayout from 'components/layouts/DefaultLayout'
+import OrganizationLayout from 'components/layouts/OrganizationLayout'
+import OrganizationSettingsLayout from 'components/layouts/ProjectLayout/OrganizationSettingsLayout'
+import type { NextPageWithLayout } from 'types'
+
+const OrgWebhookEndpointSettings: NextPageWithLayout = () => {
+ const { query } = useRouter()
+ const endpointId = Array.isArray(query.endpointId) ? query.endpointId[0] : query.endpointId
+
+ return
+}
+
+OrgWebhookEndpointSettings.getLayout = (page) => (
+
+
+ {page}
+
+
+)
+
+export default OrgWebhookEndpointSettings
diff --git a/apps/studio/pages/org/[slug]/webhooks/index.tsx b/apps/studio/pages/org/[slug]/webhooks/index.tsx
new file mode 100644
index 00000000000..20fb47fcfd6
--- /dev/null
+++ b/apps/studio/pages/org/[slug]/webhooks/index.tsx
@@ -0,0 +1,19 @@
+import { PlatformWebhooksPage } from 'components/interfaces/Platform/Webhooks'
+import DefaultLayout from 'components/layouts/DefaultLayout'
+import OrganizationLayout from 'components/layouts/OrganizationLayout'
+import OrganizationSettingsLayout from 'components/layouts/ProjectLayout/OrganizationSettingsLayout'
+import type { NextPageWithLayout } from 'types'
+
+const OrgWebhooksSettings: NextPageWithLayout = () => {
+ return
+}
+
+OrgWebhooksSettings.getLayout = (page) => (
+
+
+ {page}
+
+
+)
+
+export default OrgWebhooksSettings
diff --git a/apps/studio/pages/project/[ref]/settings/webhooks/[endpointId].tsx b/apps/studio/pages/project/[ref]/settings/webhooks/[endpointId].tsx
new file mode 100644
index 00000000000..61e703815c6
--- /dev/null
+++ b/apps/studio/pages/project/[ref]/settings/webhooks/[endpointId].tsx
@@ -0,0 +1,21 @@
+import { useRouter } from 'next/router'
+
+import { PlatformWebhooksPage } from 'components/interfaces/Platform/Webhooks'
+import DefaultLayout from 'components/layouts/DefaultLayout'
+import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout'
+import type { NextPageWithLayout } from 'types'
+
+const ProjectWebhookEndpointSettings: NextPageWithLayout = () => {
+ const { query } = useRouter()
+ const endpointId = Array.isArray(query.endpointId) ? query.endpointId[0] : query.endpointId
+
+ return
+}
+
+ProjectWebhookEndpointSettings.getLayout = (page) => (
+
+ {page}
+
+)
+
+export default ProjectWebhookEndpointSettings
diff --git a/apps/studio/pages/project/[ref]/settings/webhooks/index.tsx b/apps/studio/pages/project/[ref]/settings/webhooks/index.tsx
new file mode 100644
index 00000000000..346efe7fcc7
--- /dev/null
+++ b/apps/studio/pages/project/[ref]/settings/webhooks/index.tsx
@@ -0,0 +1,16 @@
+import { PlatformWebhooksPage } from 'components/interfaces/Platform/Webhooks'
+import DefaultLayout from 'components/layouts/DefaultLayout'
+import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout'
+import type { NextPageWithLayout } from 'types'
+
+const ProjectWebhooksSettings: NextPageWithLayout = () => {
+ return
+}
+
+ProjectWebhooksSettings.getLayout = (page) => (
+
+ {page}
+
+)
+
+export default ProjectWebhooksSettings
diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts
index b0ffc6549e7..361262d2f3c 100644
--- a/packages/common/constants/local-storage.ts
+++ b/packages/common/constants/local-storage.ts
@@ -24,6 +24,7 @@ export const LOCAL_STORAGE_KEYS = {
UI_PREVIEW_QUEUE_OPERATIONS: 'supabase-ui-queue-operations',
UI_PREVIEW_TABLE_FILTER_BAR: 'supabase-ui-table-filter-bar',
UI_PREVIEW_PG_DELTA_DIFF: 'supabase-ui-pg-delta-diff',
+ UI_PREVIEW_PLATFORM_WEBHOOKS: 'supabase-ui-platform-webhooks',
NEW_LAYOUT_NOTICE_ACKNOWLEDGED: 'new-layout-notice-acknowledge',
TABS_INTERFACE_ACKNOWLEDGED: 'tabs-interface-acknowledge',
@@ -142,6 +143,7 @@ const LOCAL_STORAGE_KEYS_ALLOWLIST = [
LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS,
LOCAL_STORAGE_KEYS.UI_PREVIEW_QUEUE_OPERATIONS,
LOCAL_STORAGE_KEYS.UI_PREVIEW_TABLE_FILTER_BAR,
+ LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS,
LOCAL_STORAGE_KEYS.LAST_SIGN_IN_METHOD,
LOCAL_STORAGE_KEYS.HIDE_PROMO_TOAST,
LOCAL_STORAGE_KEYS.BLOG_VIEW,