fix: align webhook FE with updated API changes (#51800)

## What
Aligns the webhooks FE with backend changes confirmed by Pawel
(FE-4621):

1. `custom_headers` is optional but never null, matches the updated
contract.
2. Removed the client-only `name` field (never persisted by the API).
Display name is now the endpoint's URL plus its subscribed event types.
3. The deliveries list endpoint now returns the full `event` object, so
`eventType`/`eventPayload` are always populated. Added an Event column
to the deliveries table.

Also regenerates `packages/api-types` from production to pick up these
changes.

While testing, found and fixed two more related issues:

4. A delivery can briefly exist before the backend finishes attaching
its event data, regardless of what triggered it (test, retry, or real
traffic). The deliveries list now polls every 5s while open, so new or
still-settling deliveries fill in automatically instead of requiring a
manual refresh.
5. "Send test event" always defaulted to an unrelated sample event type.
The test endpoint actually accepts a specific `event_type`, so this now
sends the endpoint's own subscribed type automatically, or shows a
picker when there's more than one to choose from.

## Testing
All webhooks component/unit tests pass (30/30), typecheck and lint
clean. Manually verified on localhost against a live backend.

## Note
This may clash with #51614 (`custom_headers: null` in a test fixture, no
longer a valid value once this merges). Will follow up there after this
lands.
This commit is contained in:
Monica Khoury authored and GitHub committed 2026-10-15 20:17:56 +03:00
1 parent 0e5753aac7
commit d0cf8dbc50
18 files changed
+395 -337

No files matched your search

@@ -12,8 +12,6 @@ export interface WebhookHeader {
export interface WebhookEndpoint {
id: string
// No `name` field in the real API — client-only until backend adds one.
name: string
url: string
description: string
enabled: boolean
@@ -27,10 +25,8 @@ export interface WebhookDelivery {
id: string
endpointId: string
eventId: string
// Only present when fetched via the single-delivery endpoint (drill-down view) —
// the deliveries *list* endpoint doesn't return either, so list rows leave these unset.
eventType?: string
eventPayload?: Record<string, unknown>
eventType: string
eventPayload: Record<string, unknown>
status: WebhookDeliveryStatus
responseCode: number
responseBody: string | Record<string, string> | null
@@ -50,7 +46,6 @@ export interface PlatformWebhooksState {
}
export interface UpsertWebhookEndpointInput {
name: string
url: string
description: string
enabled: boolean
@@ -1,16 +1,14 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import type { WebhookDelivery, WebhookEndpoint } from './PlatformWebhooks.types'
import {
filterWebhookDeliveries,
filterWebhookEndpoints,
generateWebhookEndpointName,
getWebhookEndpointDisplayName,
} from './PlatformWebhooks.utils'
const createEndpoint = (overrides: Partial<WebhookEndpoint> = {}): WebhookEndpoint => ({
id: 'endpoint-1',
name: '',
url: 'https://hooks.example.com/billing',
description: '',
enabled: true,
@@ -25,6 +23,8 @@ const createDelivery = (overrides: Partial<WebhookDelivery> = {}): WebhookDelive
id: 'delivery-1',
endpointId: 'endpoint-1',
eventId: 'event-1',
eventType: 'v1.project.updated',
eventPayload: {},
status: 'success',
responseCode: 200,
responseBody: null,
@@ -34,40 +34,29 @@ const createDelivery = (overrides: Partial<WebhookDelivery> = {}): WebhookDelive
})
describe('PlatformWebhooks.utils', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('returns the trimmed endpoint name when present', () => {
it('combines the endpoint url and subscribed event types', () => {
expect(
getWebhookEndpointDisplayName({
name: ' Billing events ',
url: 'https://hooks.example.com/billing',
eventTypes: ['v1.project.paused', 'v1.project.created'],
})
).toBe('Billing events')
).toBe('https://hooks.example.com/billing (v1.project.paused, v1.project.created)')
})
it('falls back to the endpoint url when the name is empty', () => {
it('shows "all events" when subscribed to every event type', () => {
expect(
getWebhookEndpointDisplayName({
name: ' ',
url: 'https://hooks.example.com/fallback',
url: 'https://hooks.example.com/billing',
eventTypes: ['*'],
})
).toBe('https://hooks.example.com/fallback')
).toBe('https://hooks.example.com/billing (all events)')
})
it('generates an adjective-noun endpoint name', () => {
vi.spyOn(Math, 'random').mockReturnValueOnce(0).mockReturnValueOnce(0.1)
expect(generateWebhookEndpointName()).toBe('swift-courier')
})
it('filters endpoints by name, url, and description', () => {
it('filters endpoints by url and description', () => {
const endpoints = [
createEndpoint({ id: '1', name: 'Billing events', url: 'https://hooks.example.com/billing' }),
createEndpoint({ id: '1', url: 'https://hooks.example.com/billing' }),
createEndpoint({
id: '2',
name: 'Slack alerts',
url: 'https://hooks.slack.com/x',
description: 'operational alerts',
}),
@@ -1,50 +1,5 @@
import type { WebhookDelivery, WebhookEndpoint } from './PlatformWebhooks.types'
const WEBHOOK_NAME_ADJECTIVES = [
'swift',
'winged',
'wayfinding',
'moonlit',
'fleet',
'nimble',
'roving',
'brisk',
'gliding',
'steady',
'northbound',
'starlit',
'quiet',
'amber',
'far-flung',
'secret',
'flying',
]
const WEBHOOK_NAME_NOUNS = [
'pigeon',
'courier',
'postmark',
'relay',
'dispatch',
'lantern',
'beacon',
'messenger',
'waypost',
'sparrow',
'satchel',
'signalfire',
'envelope',
'parcel',
'gull',
'kite',
'beagle',
]
const getRandomItem = (values: string[]) => values[Math.floor(Math.random() * values.length)]
export const generateWebhookEndpointName = () =>
`${getRandomItem(WEBHOOK_NAME_ADJECTIVES)}-${getRandomItem(WEBHOOK_NAME_NOUNS)}`
const secureRandomHex = (length: number) => {
if (length <= 0) return ''
@@ -64,8 +19,19 @@ const secureRandomHex = (length: number) => {
export const generateSigningSecret = () => `whsec_${secureRandomHex(16)}`
export const getWebhookEndpointDisplayName = (endpoint: Pick<WebhookEndpoint, 'name' | 'url'>) =>
endpoint.name.trim() || endpoint.url
export const getWebhookEndpointDisplayName = (
endpoint: Pick<WebhookEndpoint, 'url' | 'eventTypes'>
) => {
const eventTypesLabel = endpoint.eventTypes.includes('*')
? 'all events'
: endpoint.eventTypes.join(', ')
return `${endpoint.url} (${eventTypesLabel})`
}
export const getTestWebhookEventType = (
subscribedEventTypes: string[],
fallbackEventTypes: string[]
) => subscribedEventTypes.find((eventType) => eventType !== '*') ?? fallbackEventTypes[0]
const normalizeSearch = (value: string) => value.trim().toLowerCase()
@@ -46,7 +46,6 @@ const SELECTED_ENDPOINT_ID = '7f2c9d4a-6e31-4d9d-9a1f-2c4b5e6f7081'
const selectedEndpoint: WebhookEndpoint = {
id: SELECTED_ENDPOINT_ID,
name: 'Lovable production',
url: 'https://api.lovable.dev/webhooks/supabase',
description: 'Primary organization webhook endpoint',
enabled: true,
@@ -61,6 +60,7 @@ const createDelivery = (overrides: Partial<WebhookDelivery>): WebhookDelivery =>
endpointId: SELECTED_ENDPOINT_ID,
eventId: 'event',
eventType: 'v1.project.updated',
eventPayload: {},
status: 'success',
responseCode: 200,
responseBody: null,
@@ -78,6 +78,11 @@ const DELIVERY_COLUMNS: ColumnDef<WebhookDelivery>[] = [
<Badge variant={statusBadgeVariant[row.original.status]}>{row.original.status}</Badge>
),
},
{
accessorKey: 'eventType',
header: ({ column }) => <TanStackTableHeadSort column={column}>Event</TanStackTableHeadSort>,
cell: ({ row }) => <code className="text-code-inline text-xs">{row.original.eventType}</code>,
},
{
accessorKey: 'responseCode',
header: ({ column }) => <TanStackTableHeadSort column={column}>Response</TanStackTableHeadSort>,
@@ -158,7 +163,6 @@ export const PlatformWebhooksEndpointDetails = ({
onRetryDelivery,
}: PlatformWebhooksEndpointDetailsProps) => {
const hasCustomHeaders = selectedEndpoint.customHeaders.length > 0
const hasName = selectedEndpoint.name.trim().length > 0
const hasDescription = selectedEndpoint.description.trim().length > 0
const deliverySearchRef = useRef<HTMLInputElement>(null)
const [sorting, setSorting] = useState<SortingState>(DEFAULT_DELIVERY_SORTING)
@@ -204,8 +208,6 @@ export const PlatformWebhooksEndpointDetails = ({
<Card className="overflow-hidden">
<CardContent className="pb-5">
<dl className="grid grid-cols-1 gap-x-10 gap-y-6 md:grid-cols-2">
{hasName && <DetailItem label="Name">{selectedEndpoint.name}</DetailItem>}
<DetailItem label="URL" ddClassName="flex items-start gap-2 text-sm">
<span className="break-all">{selectedEndpoint.url}</span>
<ShortcutTooltip
@@ -30,7 +30,6 @@ import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational'
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
import type { WebhookEndpoint } from './PlatformWebhooks.types'
import { getWebhookEndpointDisplayName } from './PlatformWebhooks.utils'
import { Shortcut } from '@/components/ui/Shortcut'
import { onSearchInputEscape } from '@/lib/keyboard'
import { createNavigationHandler } from '@/lib/navigation'
@@ -170,9 +169,6 @@ export const PlatformWebhooksEndpointList = ({
</TableHeader>
<TableBody>
{sortedEndpoints.map((endpoint) => {
const hasName = endpoint.name.trim().length > 0
const displayName = getWebhookEndpointDisplayName(endpoint)
return (
<TableRow
key={endpoint.id}
@@ -197,12 +193,7 @@ export const PlatformWebhooksEndpointList = ({
</Badge>
</TableCell>
<TableCell className="max-w-[420px]">
<p className="truncate">{displayName}</p>
{hasName && (
<p className="text-xs text-foreground-lighter truncate mt-0.5">
{endpoint.url}
</p>
)}
<p className="truncate">{endpoint.url}</p>
</TableCell>
<TableCell className="max-w-[280px] truncate">
{formatEventCount(endpoint.eventTypes)}
@@ -7,23 +7,10 @@ import type { WebhookEndpoint } from './PlatformWebhooks.types'
import { PlatformWebhooksEndpointSheet, toEndpointPayload } from './PlatformWebhooksEndpointSheet'
import { customRender } from '@/tests/lib/custom-render'
const { generateWebhookEndpointNameMock } = vi.hoisted(() => ({
generateWebhookEndpointNameMock: vi.fn(() => 'winged-envelope'),
}))
vi.mock(import('./PlatformWebhooks.utils'), async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
generateWebhookEndpointName: generateWebhookEndpointNameMock,
}
})
const PROJECT_EVENT_TYPES = ['project.updated', 'project.paused']
const createEndpoint = (overrides?: Partial<WebhookEndpoint>): WebhookEndpoint => ({
id: '3c9b7e21-8d54-4f63-b2a1-6e7d8c9f0a12',
name: 'Billing events',
url: 'https://hooks.example.com/billing',
description: 'Invoices and receipts',
enabled: true,
@@ -65,44 +52,16 @@ const findEventTypeCheckbox = (eventType: string) =>
name: new RegExp(eventType.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
})
describe('PlatformWebhooksEndpointSheet', () => {
it('prefills the name field when creating an endpoint', async () => {
renderEndpointSheet()
expect(await screen.findByDisplayValue('winged-envelope')).toBeInTheDocument()
})
it('loads the existing name and description in edit mode', async () => {
it('loads the existing url and description in edit mode', async () => {
renderEndpointSheet({
mode: 'edit',
endpoint: createEndpoint(),
})
expect(await screen.findByDisplayValue('Billing events')).toBeInTheDocument()
expect(await screen.findByDisplayValue('https://hooks.example.com/billing')).toBeInTheDocument()
expect(screen.getByDisplayValue('Invoices and receipts')).toBeInTheDocument()
})
it('submits an empty name when an existing name is cleared', async () => {
const user = userEvent.setup()
const { onSubmit } = renderEndpointSheet({
mode: 'edit',
endpoint: createEndpoint(),
})
const nameInput = await screen.findByDisplayValue('Billing events')
await user.clear(nameInput)
submitForm()
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
name: '',
url: 'https://hooks.example.com/billing',
description: 'Invoices and receipts',
}),
expect.anything()
)
})
it('blocks submit when the endpoint URL is empty', async () => {
const user = userEvent.setup()
const { onSubmit } = renderEndpointSheet()
@@ -248,7 +207,6 @@ describe('PlatformWebhooksEndpointSheet', () => {
it('strips fully empty custom header rows from the payload', () => {
expect(
toEndpointPayload({
name: 'Billing events',
url: 'https://hooks.example.com/billing',
description: '',
enabled: true,
@@ -39,7 +39,7 @@ import type {
WebhookEndpoint,
WebhookScope,
} from './PlatformWebhooks.types'
import { generateSigningSecret, generateWebhookEndpointName } from './PlatformWebhooks.utils'
import { generateSigningSecret } from './PlatformWebhooks.utils'
import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
import { InlineLink } from '@/components/ui/InlineLink'
import { Shortcut } from '@/components/ui/Shortcut'
@@ -50,7 +50,6 @@ import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
const buildEndpointFormSchema = (mode: 'create' | 'edit') =>
z
.object({
name: z.string().trim().max(64, 'Name cannot exceed 64 characters'),
url: httpEndpointUrlSchema({
requiredMessage: 'Please provide a URL',
invalidMessage: 'Please provide a valid URL',
@@ -153,7 +152,6 @@ const toControlId = (prefix: string, value: string) =>
`${prefix}-${value.replace(/[^a-zA-Z0-9_-]/g, '-')}`
export const toEndpointPayload = (values: EndpointFormValues): UpsertWebhookEndpointInput => ({
name: values.name,
url: values.url,
description: values.description,
enabled: values.enabled,
@@ -192,7 +190,6 @@ export const PlatformWebhooksEndpointSheet = ({
const form = useForm<EndpointFormValues>({
resolver: zodResolver(buildEndpointFormSchema(mode)),
defaultValues: {
name: generateWebhookEndpointName(),
url: '',
description: '',
enabled: true,
@@ -225,7 +222,6 @@ export const PlatformWebhooksEndpointSheet = ({
if (!endpoint) {
form.reset({
name: generateWebhookEndpointName(),
url: '',
description: '',
enabled: true,
@@ -238,7 +234,6 @@ export const PlatformWebhooksEndpointSheet = ({
}
form.reset({
name: endpoint.name,
url: endpoint.url,
description: endpoint.description,
enabled: enabledOverride ?? endpoint.enabled,
@@ -278,8 +273,8 @@ export const PlatformWebhooksEndpointSheet = ({
<SheetTitle>{mode === 'create' ? 'Create endpoint' : 'Edit endpoint'}</SheetTitle>
<SheetDescription className="sr-only">
{mode === 'create'
? 'Create a webhook endpoint by setting a name, URL, and event subscriptions.'
: 'Edit this webhook endpoint name, URL, and event subscriptions.'}
? 'Create a webhook endpoint by setting a URL and event subscriptions.'
: 'Edit this webhook endpoint URL and event subscriptions.'}
</SheetDescription>
</SheetHeader>
<Separator />
@@ -291,27 +286,6 @@ export const PlatformWebhooksEndpointSheet = ({
onSubmit={form.handleSubmit(onSubmit)}
>
<div className="px-5 space-y-5">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout
label={
<>
Name
{/* Technically optional but encourage, so no (optional) label */}
</>
}
layout="vertical"
className="gap-1"
>
<FormControl>
<Input {...field} placeholder="winged-envelope" maxLength={64} />
</FormControl>
</FormItemLayout>
)}
/>
<FormField
control={form.control}
name="url"
@@ -20,6 +20,11 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { Input } from 'ui-patterns/DataInputs/Input'
@@ -32,6 +37,7 @@ import {
filterWebhookDeliveries,
filterWebhookEndpoints,
generateSigningSecret,
getTestWebhookEventType,
getWebhookEndpointDisplayName,
} from './PlatformWebhooks.utils'
import { PlatformWebhooksDeliveryDetailsSheet } from './PlatformWebhooksDeliveryDetailsSheet'
@@ -120,6 +126,8 @@ export const PlatformWebhooksPage = ({ scope, endpointId }: PlatformWebhooksPage
null
)
const [showRegenerateSecretConfirm, setShowRegenerateSecretConfirm] = useState(false)
const [showTestEventPicker, setShowTestEventPicker] = useState(false)
const [testEventType, setTestEventType] = useState('')
const [editEnabledOverride, setEditEnabledOverride] = useState<boolean | null>(null)
const [deliveryDetailsTab, setDeliveryDetailsTab] = useState<'event' | 'response'>('event')
const [pendingCreatedEndpointId, setPendingCreatedEndpointId] = useState<string | null>(null)
@@ -159,33 +167,22 @@ export const PlatformWebhooksPage = ({ scope, endpointId }: PlatformWebhooksPage
)
const isEndpointView = !!selectedEndpoint
const selectedEndpointHasName = selectedEndpoint ? selectedEndpoint.name.trim().length > 0 : false
const selectedEndpointDisplayName = selectedEndpoint
? getWebhookEndpointDisplayName(selectedEndpoint)
: ''
const headerTitle = isEndpointView ? selectedEndpointDisplayName : scopeLabel
const headerDescription = isEndpointView
? selectedEndpointHasName
? (selectedEndpoint?.url ?? '')
: ''
: scopeDescription
const headerDescription = isEndpointView ? '' : scopeDescription
const endpointPendingDelete = useMemo(
() => endpoints.find((endpoint) => endpoint.id === endpointIdPendingDelete) ?? null,
[endpoints, endpointIdPendingDelete]
)
const endpointPendingDeleteHasName = endpointPendingDelete
? endpointPendingDelete.name.trim().length > 0
: false
const endpointPendingDeleteDisplayName = endpointPendingDelete
? getWebhookEndpointDisplayName(endpointPendingDelete)
: ''
let deleteEndpointDescription = 'This action cannot be undone.'
if (endpointPendingDelete) {
deleteEndpointDescription = endpointPendingDeleteHasName
? `Deleting “${endpointPendingDeleteDisplayName}” stops all deliveries to the URL below. This can’t be undone.`
: 'Deleting this endpoint stops all deliveries to the URL below. This can’t be undone.'
}
const deleteEndpointDescription = endpointPendingDelete
? `Deleting “${endpointPendingDeleteDisplayName}” stops all deliveries to the URL below. This can’t be undone.`
: 'This action cannot be undone.'
const shouldRedirect = shouldRedirectFromWebhooks({
areFlagsReady: isFeaturePreviewInitialized,
@@ -360,10 +357,14 @@ export const PlatformWebhooksPage = ({ scope, endpointId }: PlatformWebhooksPage
toast.success('Signing secret regenerated')
}
const handleSendTestEvent = async () => {
const handleSendTestEvent = async (eventType: string) => {
if (!selectedEndpoint) return
try {
await testEndpointMutation.mutateAsync({ scope: apiScope, id: selectedEndpoint.id })
await testEndpointMutation.mutateAsync({
scope: apiScope,
id: selectedEndpoint.id,
eventType,
})
} catch (error) {
console.error('Failed to send webhook test event', error)
return
@@ -371,6 +372,23 @@ export const PlatformWebhooksPage = ({ scope, endpointId }: PlatformWebhooksPage
toast.success('Test event sent — check deliveries shortly')
}
const testEventTypeChoices = selectedEndpoint
? selectedEndpoint.eventTypes.includes('*')
? eventTypeOptions
: selectedEndpoint.eventTypes
: []
const handleSendTestEventClick = () => {
if (!selectedEndpoint) return
if (testEventTypeChoices.length <= 1) {
const eventType = getTestWebhookEventType(selectedEndpoint.eventTypes, eventTypeOptions)
if (eventType) handleSendTestEvent(eventType)
return
}
setTestEventType(testEventTypeChoices[0])
setShowTestEventPicker(true)
}
const handleRetryDelivery = async (deliveryId: string) => {
if (!selectedEndpoint) return
const delivery = deliveries.find((item) => item.id === deliveryId)
@@ -462,7 +480,7 @@ export const PlatformWebhooksPage = ({ scope, endpointId }: PlatformWebhooksPage
<Button icon={<EllipsisVerticalIcon />} className="w-7" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom" className="w-48">
<DropdownMenuItem className="gap-x-2" onClick={handleSendTestEvent}>
<DropdownMenuItem className="gap-x-2" onClick={handleSendTestEventClick}>
<SendIcon size={14} className="text-foreground-lighter" />
<span>Send test event</span>
</DropdownMenuItem>
@@ -574,6 +592,42 @@ export const PlatformWebhooksPage = ({ scope, endpointId }: PlatformWebhooksPage
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={showTestEventPicker} onOpenChange={setShowTestEventPicker}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Send test event</AlertDialogTitle>
<AlertDialogDescription>
Choose which subscribed event type to send as a test.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="px-5 pb-5">
<Select value={testEventType} onValueChange={setTestEventType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{testEventTypeChoices.map((eventType) => (
<SelectItem key={eventType} value={eventType}>
{eventType}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setShowTestEventPicker(false)
handleSendTestEvent(testEventType)
}}
>
Send
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={showRegenerateSecretConfirm} onOpenChange={setShowRegenerateSecretConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
@@ -1,8 +1,5 @@
export { PlatformWebhooksPage } from './PlatformWebhooksPage'
export {
generateWebhookEndpointName,
getWebhookEndpointDisplayName,
} from './PlatformWebhooks.utils'
export { getWebhookEndpointDisplayName } from './PlatformWebhooks.utils'
export type {
PlatformWebhooksState,
UpsertWebhookEndpointInput,
@@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'
import { platformWebhooksKeys } from './keys'
import { listWebhookDeliveries, type WebhookScopeParams } from './platform-webhooks-fetchers'
import { toWebhookDeliverySummary } from './platform-webhooks-mappers'
import { toWebhookDelivery } from './platform-webhooks-mappers'
import type { WebhookDelivery } from '@/components/interfaces/Platform/Webhooks/PlatformWebhooks.types'
import type { ResponseError, UseCustomQueryOptions } from '@/types'
@@ -19,7 +19,7 @@ async function getWebhookDeliveries(
signal?: AbortSignal
): Promise<WebhookDelivery[]> {
const result = await listWebhookDeliveries(scope, endpointId, { size, after, before }, signal)
return (result?.data ?? []).map((delivery) => toWebhookDeliverySummary(delivery, endpointId))
return (result?.data ?? []).map((delivery) => toWebhookDelivery(delivery, endpointId))
}
export type WebhookDeliveriesData = Awaited<ReturnType<typeof getWebhookDeliveries>>
@@ -38,6 +38,7 @@ export const useWebhookDeliveriesQuery = <TData = WebhookDeliveriesData>(
queryFn: ({ signal }) =>
getWebhookDeliveries({ scope, endpointId, size, after, before }, signal),
enabled: enabled && Boolean(endpointId),
refetchInterval: 5000,
...options,
})
}
@@ -28,10 +28,7 @@ async function createEndpoint({
})
if (!result?.data) throw new Error('Endpoint was created but no data was returned')
// The server never stores `name` (see PlatformWebhooks.types.ts) — carry through
// what the user typed so it's visible for this session, even though it won't
// survive a refetch or page reload.
return { ...toWebhookEndpoint(result.data), name: input.name }
return toWebhookEndpoint(result.data)
}
type WebhookEndpointCreateData = Awaited<ReturnType<typeof createEndpoint>>
@@ -12,10 +12,11 @@ import type { ResponseError, UseCustomMutationOptions } from '@/types'
export type WebhookEndpointTestVariables = {
scope: WebhookScopeParams
id: string
eventType?: string
}
async function testEndpoint({ scope, id }: WebhookEndpointTestVariables) {
return sendTestWebhookEvent(scope, id)
async function testEndpoint({ scope, id, eventType }: WebhookEndpointTestVariables) {
return sendTestWebhookEvent(scope, id, eventType)
}
type WebhookEndpointTestData = Awaited<ReturnType<typeof testEndpoint>>
@@ -29,9 +29,7 @@ async function updateEndpoint({
})
if (!result?.data) throw new Error('Endpoint was updated but no data was returned')
// See the comment in platform-webhook-endpoint-create-mutation.ts — `name` isn't
// persisted by the server, so it's carried through from what the user submitted.
return { ...toWebhookEndpoint(result.data), name: input.name }
return toWebhookEndpoint(result.data)
}
type WebhookEndpointUpdateData = Awaited<ReturnType<typeof updateEndpoint>>
@@ -30,7 +30,7 @@ export interface WebhookEndpointAttributesInput {
description?: string | null
enabled?: boolean
event_types: { type: WebhookEventType }[]
custom_headers?: Record<string, string> | null
custom_headers?: Record<string, string>
signing_secret?: string
}
@@ -195,16 +195,29 @@ export async function getWebhookDelivery(
return data
}
export async function sendTestWebhookEvent(scope: WebhookScopeParams, id: string) {
export async function sendTestWebhookEvent(
scope: WebhookScopeParams,
id: string,
eventType?: string
) {
const body = eventType
? {
data: {
type: 'event' as const,
attributes: { type: eventType as Exclude<WebhookEventType, '*'> },
},
}
: {}
const { data, error } =
scope.scope === 'organization'
? await post('/v2/organizations/{slug}/webhooks/endpoints/{id}/test', {
params: { path: { slug: scope.orgSlug, id } },
body: {},
body,
})
: await post('/v2/projects/{ref}/webhooks/endpoints/{id}/test', {
params: { path: { ref: scope.projectRef, id } },
body: {},
body,
})
if (error) handleError(error)
@@ -26,8 +26,6 @@ const toCustomHeaders = (
? Object.fromEntries(headers.map((header) => [header.key, header.value]))
: undefined
// The real API has no `name` field on an endpoint — it is never sent here.
// See the comment on `WebhookEndpoint.name` in PlatformWebhooks.types.ts.
export const toWebhookEndpointAttributes = (
input: UpsertWebhookEndpointInput
): WebhookEndpointAttributesInput => ({
@@ -42,20 +40,11 @@ export const toWebhookEndpointAttributes = (
type WebhookEndpointResource =
operations['v2-organizations-slug-webhooks-endpoints-id-get']['responses'][200]['content']['application/json']['data']
// The single-delivery endpoint — includes the full nested `event` (type + payload).
type WebhookDeliveryResource =
operations['v2-organizations-slug-webhooks-deliveries-id-get']['responses'][200]['content']['application/json']['data']
// The deliveries-list-for-an-endpoint endpoint — lighter weight, no `event` at all.
type WebhookDeliveryListItemResource =
operations['v2-organizations-slug-webhooks-endpoints-id-deliveries-get']['responses'][200]['content']['application/json']['data'][number]
// Response → UI direction. The real API never returns a `name` — `name` is left blank
// here; until the backend adds a field for it, there's nowhere to read a saved name
// back from. Flagged with Ali/Paweł as an open product question, not yet resolved.
export const toWebhookEndpoint = (resource: WebhookEndpointResource): WebhookEndpoint => ({
id: resource.id,
name: '',
url: resource.attributes.url,
description: resource.attributes.description ?? '',
enabled: resource.attributes.enabled,
@@ -76,24 +65,8 @@ export const toWebhookDelivery = (
id: resource.id,
endpointId,
eventId: resource.attributes.event_id,
eventType: resource.attributes.event.type,
eventPayload: resource.attributes.event.payload,
status: resource.attributes.status,
responseCode: resource.attributes.response_code,
responseBody: resource.attributes.response_body,
responseHeaders: resource.attributes.response_headers,
attemptAt: resource.attributes.attempt_timestamp,
})
// No `eventType` here — the list endpoint doesn't return it. See the comment on
// `WebhookDelivery.eventType` in PlatformWebhooks.types.ts.
export const toWebhookDeliverySummary = (
resource: WebhookDeliveryListItemResource,
endpointId: string
): WebhookDelivery => ({
id: resource.id,
endpointId,
eventId: resource.attributes.event_id,
eventType: resource.attributes.event?.type ?? '',
eventPayload: resource.attributes.event?.payload ?? {},
status: resource.attributes.status,
responseCode: resource.attributes.response_code,
responseBody: resource.attributes.response_body,
+113 -107
View File
@@ -3056,7 +3056,7 @@ export interface operations {
'application/json': components['schemas']['ErrorResponseBody']
}
}
/** @description This feature requires the Enterprise organization plan. */
/** @description This feature requires the Team, or Enterprise organization plan. */
402: {
headers: {
[name: string]: unknown
@@ -3127,7 +3127,7 @@ export interface operations {
'application/json': components['schemas']['ErrorResponseBody']
}
}
/** @description This feature requires the Enterprise organization plan. */
/** @description This feature requires the Team, or Enterprise organization plan. */
402: {
headers: {
[name: string]: unknown
@@ -3189,7 +3189,7 @@ export interface operations {
'application/json': components['schemas']['ErrorResponseBody']
}
}
/** @description This feature requires the Enterprise organization plan. */
/** @description This feature requires the Team, or Enterprise organization plan. */
402: {
headers: {
[name: string]: unknown
@@ -3354,6 +3354,8 @@ export interface operations {
* @description Timestamp of the attempt.
*/
attempt_timestamp: string
error_code: string | null
error_message: string | null
event: {
/** @description Final data sent to the consumer. */
payload: {
@@ -3394,27 +3396,6 @@ export interface operations {
| 'v1.organization.member.role.updated'
| 'v1.organization.billing.plan.upgraded'
| 'v1.organization.billing.plan.downgraded'
| 'project.v1.paused'
| 'project.v1.created'
| 'project.v1.restored'
| 'project.v1.transferred'
| 'project.v1.removed'
| 'project.v1.restarted'
| 'project.v1.status.changed'
| 'project.v1.backup.started'
| 'project.v1.branch.created'
| 'project.v1.branch.updated'
| 'project.v1.branch.removed'
| 'organization.v1.member.invitation.created'
| 'organization.v1.member.invitation.canceled'
| 'organization.v1.member.added'
| 'organization.v1.member.removed'
| 'organization.v1.member.role.assigned'
| 'organization.v1.member.role.removed'
| 'organization.v1.member.role.updated'
| 'organization.v1.billing.plan.upgraded'
| 'organization.v1.billing.plan.downgraded'
| 'project.v1.branch.deleted'
}
/**
* Format: uuid
@@ -3439,7 +3420,7 @@ export interface operations {
/** @description HTTP headers of the response, `{}` if unavailable. */
response_headers: {
[key: string]: string
} | null
}
/**
* @description Status of the delivery attempt.
* @enum {string}
@@ -4036,7 +4017,7 @@ export interface operations {
*/
custom_headers?: {
[key: string]: string
} | null
}
/** @description Optional description for the endpoint. */
description: string | null
/** @description Whether the endpoint is enabled or not - disabled endpoints won't emit any events. */
@@ -4360,7 +4341,7 @@ export interface operations {
*/
custom_headers?: {
[key: string]: string
} | null
}
/** @description Optional description for the endpoint. */
description?: string | null
/**
@@ -4450,7 +4431,7 @@ export interface operations {
*/
custom_headers?: {
[key: string]: string
} | null
}
/** @description Optional description for the endpoint. */
description: string | null
/** @description Whether the endpoint is enabled or not - disabled endpoints won't emit any events. */
@@ -4768,7 +4749,7 @@ export interface operations {
*/
custom_headers?: {
[key: string]: string
} | null
}
/** @description Optional description for the endpoint. */
description: string | null
/** @description Whether the endpoint is enabled or not - disabled endpoints won't emit any events. */
@@ -5088,7 +5069,7 @@ export interface operations {
*/
custom_headers?: {
[key: string]: string
} | null
}
/** @description Optional description for the endpoint. */
description: string | null
/** @description Whether the endpoint is enabled or not - disabled endpoints won't emit any events. */
@@ -5445,7 +5426,7 @@ export interface operations {
*/
custom_headers?: {
[key: string]: string
} | null
}
/** @description Optional description for the endpoint. */
description: string | null
/** @description Whether the endpoint is enabled or not - disabled endpoints won't emit any events. */
@@ -5786,7 +5767,7 @@ export interface operations {
*/
custom_headers?: {
[key: string]: string
} | null
}
/** @description Optional description for the endpoint. */
description?: string | null
/** @description Whether the endpoint is enabled or not - disabled endpoints won't emit any events. */
@@ -5873,7 +5854,7 @@ export interface operations {
*/
custom_headers?: {
[key: string]: string
} | null
}
/** @description Optional description for the endpoint. */
description: string | null
/** @description Whether the endpoint is enabled or not - disabled endpoints won't emit any events. */
@@ -6224,6 +6205,49 @@ export interface operations {
* @description Timestamp of the attempt.
*/
attempt_timestamp: string
error_code: string | null
error_message: string | null
event: {
/** @description Final data sent to the consumer. */
payload: {
/**
* @description Organization slug
* @example tsrqponmlkjihgfedcba
*/
organization_slug: string
project_ref: string | null
}
/**
* Format: date-time
* @description Timestamp of event publication.
*/
timestamp: string
/**
* @description Webhook event type.
* @enum {string}
*/
type:
| 'v1.project.paused'
| 'v1.project.created'
| 'v1.project.restored'
| 'v1.project.transferred'
| 'v1.project.removed'
| 'v1.project.restarted'
| 'v1.project.status.changed'
| 'v1.project.backup.started'
| 'v1.project.branch.created'
| 'v1.project.branch.updated'
| 'v1.project.branch.removed'
| 'v1.organization.member.invitation.created'
| 'v1.organization.member.invitation.canceled'
| 'v1.organization.member.added'
| 'v1.organization.member.removed'
| 'v1.organization.member.role.assigned'
| 'v1.organization.member.role.removed'
| 'v1.organization.member.role.updated'
| 'v1.organization.billing.plan.upgraded'
| 'v1.organization.billing.plan.downgraded'
}
/**
* Format: uuid
* @description Identifier of the event, which triggered the delivery (UUID v7).
@@ -6247,7 +6271,7 @@ export interface operations {
/** @description HTTP headers of the response, `{}` if unavailable. */
response_headers: {
[key: string]: string
} | null
}
/**
* @description Status of the delivery attempt.
* @enum {string}
@@ -6581,27 +6605,6 @@ export interface operations {
| 'v1.organization.member.role.updated'
| 'v1.organization.billing.plan.upgraded'
| 'v1.organization.billing.plan.downgraded'
| 'project.v1.paused'
| 'project.v1.created'
| 'project.v1.restored'
| 'project.v1.transferred'
| 'project.v1.removed'
| 'project.v1.restarted'
| 'project.v1.status.changed'
| 'project.v1.backup.started'
| 'project.v1.branch.created'
| 'project.v1.branch.updated'
| 'project.v1.branch.removed'
| 'organization.v1.member.invitation.created'
| 'organization.v1.member.invitation.canceled'
| 'organization.v1.member.added'
| 'organization.v1.member.removed'
| 'organization.v1.member.role.assigned'
| 'organization.v1.member.role.removed'
| 'organization.v1.member.role.updated'
| 'organization.v1.billing.plan.upgraded'
| 'organization.v1.billing.plan.downgraded'
| 'project.v1.branch.deleted'
}
/**
* @description Resource type.
@@ -8359,6 +8362,8 @@ export interface operations {
* @description Timestamp of the attempt.
*/
attempt_timestamp: string
error_code: string | null
error_message: string | null
event: {
/** @description Final data sent to the consumer. */
payload: {
@@ -8399,27 +8404,6 @@ export interface operations {
| 'v1.organization.member.role.updated'
| 'v1.organization.billing.plan.upgraded'
| 'v1.organization.billing.plan.downgraded'
| 'project.v1.paused'
| 'project.v1.created'
| 'project.v1.restored'
| 'project.v1.transferred'
| 'project.v1.removed'
| 'project.v1.restarted'
| 'project.v1.status.changed'
| 'project.v1.backup.started'
| 'project.v1.branch.created'
| 'project.v1.branch.updated'
| 'project.v1.branch.removed'
| 'organization.v1.member.invitation.created'
| 'organization.v1.member.invitation.canceled'
| 'organization.v1.member.added'
| 'organization.v1.member.removed'
| 'organization.v1.member.role.assigned'
| 'organization.v1.member.role.removed'
| 'organization.v1.member.role.updated'
| 'organization.v1.billing.plan.upgraded'
| 'organization.v1.billing.plan.downgraded'
| 'project.v1.branch.deleted'
}
/**
* Format: uuid
@@ -8444,7 +8428,7 @@ export interface operations {
/** @description HTTP headers of the response, `{}` if unavailable. */
response_headers: {
[key: string]: string
} | null
}
/**
* @description Status of the delivery attempt.
* @enum {string}
@@ -9041,7 +9025,7 @@ export interface operations {
*/
custom_headers?: {
[key: string]: string
} | null
}
/** @description Optional description for the endpoint. */
description: string | null
/** @description Whether the endpoint is enabled or not - disabled endpoints won't emit any events. */
@@ -9365,7 +9349,7 @@ export interface operations {
*/
custom_headers?: {
[key: string]: string
} | null
}
/** @description Optional description for the endpoint. */
description?: string | null
/**
@@ -9455,7 +9439,7 @@ export interface operations {
*/
custom_headers?: {
[key: string]: string
} | null
}
/** @description Optional description for the endpoint. */
description: string | null
/** @description Whether the endpoint is enabled or not - disabled endpoints won't emit any events. */
@@ -9773,7 +9757,7 @@ export interface operations {
*/
custom_headers?: {
[key: string]: string
} | null
}
/** @description Optional description for the endpoint. */
description: string | null
/** @description Whether the endpoint is enabled or not - disabled endpoints won't emit any events. */
@@ -10093,7 +10077,7 @@ export interface operations {
*/
custom_headers?: {
[key: string]: string
} | null
}
/** @description Optional description for the endpoint. */
description: string | null
/** @description Whether the endpoint is enabled or not - disabled endpoints won't emit any events. */
@@ -10450,7 +10434,7 @@ export interface operations {
*/
custom_headers?: {
[key: string]: string
} | null
}
/** @description Optional description for the endpoint. */
description: string | null
/** @description Whether the endpoint is enabled or not - disabled endpoints won't emit any events. */
@@ -10791,7 +10775,7 @@ export interface operations {
*/
custom_headers?: {
[key: string]: string
} | null
}
/** @description Optional description for the endpoint. */
description?: string | null
/** @description Whether the endpoint is enabled or not - disabled endpoints won't emit any events. */
@@ -10878,7 +10862,7 @@ export interface operations {
*/
custom_headers?: {
[key: string]: string
} | null
}
/** @description Optional description for the endpoint. */
description: string | null
/** @description Whether the endpoint is enabled or not - disabled endpoints won't emit any events. */
@@ -11229,6 +11213,49 @@ export interface operations {
* @description Timestamp of the attempt.
*/
attempt_timestamp: string
error_code: string | null
error_message: string | null
event: {
/** @description Final data sent to the consumer. */
payload: {
/**
* @description Organization slug
* @example tsrqponmlkjihgfedcba
*/
organization_slug: string
project_ref: string | null
}
/**
* Format: date-time
* @description Timestamp of event publication.
*/
timestamp: string
/**
* @description Webhook event type.
* @enum {string}
*/
type:
| 'v1.project.paused'
| 'v1.project.created'
| 'v1.project.restored'
| 'v1.project.transferred'
| 'v1.project.removed'
| 'v1.project.restarted'
| 'v1.project.status.changed'
| 'v1.project.backup.started'
| 'v1.project.branch.created'
| 'v1.project.branch.updated'
| 'v1.project.branch.removed'
| 'v1.organization.member.invitation.created'
| 'v1.organization.member.invitation.canceled'
| 'v1.organization.member.added'
| 'v1.organization.member.removed'
| 'v1.organization.member.role.assigned'
| 'v1.organization.member.role.removed'
| 'v1.organization.member.role.updated'
| 'v1.organization.billing.plan.upgraded'
| 'v1.organization.billing.plan.downgraded'
}
/**
* Format: uuid
* @description Identifier of the event, which triggered the delivery (UUID v7).
@@ -11252,7 +11279,7 @@ export interface operations {
/** @description HTTP headers of the response, `{}` if unavailable. */
response_headers: {
[key: string]: string
} | null
}
/**
* @description Status of the delivery attempt.
* @enum {string}
@@ -11586,27 +11613,6 @@ export interface operations {
| 'v1.organization.member.role.updated'
| 'v1.organization.billing.plan.upgraded'
| 'v1.organization.billing.plan.downgraded'
| 'project.v1.paused'
| 'project.v1.created'
| 'project.v1.restored'
| 'project.v1.transferred'
| 'project.v1.removed'
| 'project.v1.restarted'
| 'project.v1.status.changed'
| 'project.v1.backup.started'
| 'project.v1.branch.created'
| 'project.v1.branch.updated'
| 'project.v1.branch.removed'
| 'organization.v1.member.invitation.created'
| 'organization.v1.member.invitation.canceled'
| 'organization.v1.member.added'
| 'organization.v1.member.removed'
| 'organization.v1.member.role.assigned'
| 'organization.v1.member.role.removed'
| 'organization.v1.member.role.updated'
| 'organization.v1.billing.plan.upgraded'
| 'organization.v1.billing.plan.downgraded'
| 'project.v1.branch.deleted'
}
/**
* @description Resource type.
+143
View File
@@ -435,6 +435,24 @@ export interface paths {
patch?: never
trace?: never
}
'/platform/database/{ref}/db-integrity/operations/{op}': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
/** Reads the current db-integrity task for an operation on the project primary. */
get: operations['DatabaseDbIntegrityController_status']
put?: never
/** Submits a db-integrity operation on the project primary. */
post: operations['DatabaseDbIntegrityController_submit']
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/platform/database/{ref}/hook-enable': {
parameters: {
query?: never
@@ -6729,6 +6747,8 @@ export interface components {
| 'audit_logs_read'
| 'billing_read'
| 'billing_write'
| 'organization_config_read'
| 'organization_config_write'
| 'members_read'
| 'members_write'
| 'organization_projects_read'
@@ -6749,8 +6769,12 @@ export interface components {
| 'api_gateway_keys_write'
| 'auth_config_read'
| 'auth_config_write'
| 'auth_hooks_read'
| 'auth_hooks_write'
| 'auth_signing_keys_read'
| 'auth_signing_keys_write'
| 'auth_users_read'
| 'auth_users_write'
| 'backups_read'
| 'backups_write'
| 'branching_development_create'
@@ -6852,6 +6876,7 @@ export interface components {
| 't4g.nano'
| 't3a.nano'
| 't3.nano'
| 't8i.nano'
| 't4g.micro'
| 't3a.micro'
| 't3.micro'
@@ -7693,6 +7718,8 @@ export interface components {
| 'audit_logs_read'
| 'billing_read'
| 'billing_write'
| 'organization_config_read'
| 'organization_config_write'
| 'members_read'
| 'members_write'
| 'organization_projects_read'
@@ -7713,8 +7740,12 @@ export interface components {
| 'api_gateway_keys_write'
| 'auth_config_read'
| 'auth_config_write'
| 'auth_hooks_read'
| 'auth_hooks_write'
| 'auth_signing_keys_read'
| 'auth_signing_keys_write'
| 'auth_users_read'
| 'auth_users_write'
| 'backups_read'
| 'backups_write'
| 'branching_development_create'
@@ -8075,6 +8106,22 @@ export interface components {
| 'RESTARTING'
| 'RESIZING'
}
DbIntegritySubmitBody: {
idempotency_key?: string
metadata?: {
[key: string]: string
}
parameters?: {
[key: string]: unknown
}
rerun?: boolean
}
DbIntegrityTaskResponse_Output: {
error?: string
host_lock_yield?: unknown
result?: unknown
status: string
}
DeclineAuthorizationResponse_Output: {
id: string
}
@@ -14311,6 +14358,8 @@ export interface components {
| 'audit_logs_read'
| 'billing_read'
| 'billing_write'
| 'organization_config_read'
| 'organization_config_write'
| 'members_read'
| 'members_write'
| 'organization_projects_read'
@@ -14331,8 +14380,12 @@ export interface components {
| 'api_gateway_keys_write'
| 'auth_config_read'
| 'auth_config_write'
| 'auth_hooks_read'
| 'auth_hooks_write'
| 'auth_signing_keys_read'
| 'auth_signing_keys_write'
| 'auth_users_read'
| 'auth_users_write'
| 'backups_read'
| 'backups_write'
| 'branching_development_create'
@@ -17841,6 +17894,96 @@ export interface operations {
}
}
}
DatabaseDbIntegrityController_status: {
parameters: {
query?: never
header?: never
path: {
op: 'collation' | 'collation_check'
ref: string
}
cookie?: never
}
requestBody?: never
responses: {
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['DbIntegrityTaskResponse_Output']
}
}
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Forbidden action */
403: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Rate limit exceeded */
429: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
DatabaseDbIntegrityController_submit: {
parameters: {
query?: never
header?: never
path: {
op: 'collation' | 'collation_check'
ref: string
}
cookie?: never
}
requestBody: {
content: {
'application/json': components['schemas']['DbIntegritySubmitBody']
}
}
responses: {
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['DbIntegrityTaskResponse_Output']
}
}
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Forbidden action */
403: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Rate limit exceeded */
429: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
HooksController_enableHooks: {
parameters: {
query?: never