Webhook Endpoints
Manage webhook endpoints: recent delivery dots per endpoint, send a test ping, reveal the signing secret, disable.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/webhook-list.json'use client'
import { useState } from 'react'
import './webhook-list.css'
export interface Webhook { id: string; url: string; events: string[]; on: boolean; history: boolean[]; secret: string }
export interface WebhookListProps {
endpoints: Webhook[]
ping?: (id: string) => Promise<boolean>
defaultOpen?: string | null
fill?: 'dark' | 'light'
className?: string
}
/** Webhook endpoint list with test pings. */
export function WebhookList({ endpoints, ping = async () => true, defaultOpen = null, fill = 'dark', className }: WebhookListProps) {
const [es, setEs] = useState(endpoints)
const [open, setOpen] = useState(defaultOpen)
const [show, setShow] = useState<string | null>(null)
const [busy, setBusy] = useState<string | null>(null)
const [last, setLast] = useState<{ id: string; ok: boolean } | null>(null)
const upd = (id: string, f: (w: Webhook) => Webhook) => setEs((x) => x.map((w) => (w.id === id ? f(w) : w)))
const test = async (id: string) => { setBusy(id); const ok = await ping(id); upd(id, (w) => ({ ...w, history: [...w.history.slice(-9), ok] })); setLast({ id, ok }); setBusy(null) }
return (
<div className={['sd-whk', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-whk__h"><b>Webhooks</b><button type="button">+ Add endpoint</button></div>
{es.map((w) => {
const ok = w.history.filter(Boolean).length
return (
<div key={w.id} className="sd-whk__e" data-off={!w.on || undefined}>
<div className="sd-whk__row">
<button type="button" aria-expanded={open === w.id} onClick={() => setOpen(open === w.id ? null : w.id)}>
<code>{w.url.replace(/^https:\/\//, '')}</code>
<span className="sd-whk__dots" aria-label={`${ok} of ${w.history.length} recent deliveries succeeded`}>{w.history.map((h, i) => <i key={i + '-' + w.history.length} data-fail={!h || undefined} data-new={(last?.id === w.id && i === w.history.length - 1) || undefined} />)}<small>{ok}/{w.history.length}</small></span>
</button>
<button type="button" role="switch" className="sd-whk__sw" aria-checked={w.on} aria-label={`Enable ${w.url}`} onClick={() => upd(w.id, (x) => ({ ...x, on: !x.on }))} />
</div>
{open === w.id && (
<div className="sd-whk__d">
<div className="sd-whk__ev">{w.events.map((e) => <span key={e}>{e}</span>)}</div>
<div className="sd-whk__sec"><code>{show === w.id ? w.secret : 'whsec_' + '•'.repeat(18)}</code><button type="button" aria-pressed={show === w.id} onClick={() => setShow(show === w.id ? null : w.id)}>{show === w.id ? 'Hide' : 'Reveal'}</button></div>
<div className="sd-whk__act"><span aria-live="polite">{busy === w.id ? 'Sending ping…' : last?.id === w.id ? (last.ok ? 'Ping delivered · 200 OK' : 'Ping failed · 500') : `${w.events.length} events`}</span><button type="button" data-pri disabled={!w.on || busy === w.id} onClick={() => test(w.id)}>Send test</button></div>
</div>
)}
</div>
)
})}
</div>
)
}.sd-whk { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-whk[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-whk :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-whk :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-whk .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-whk { width: min(100%, 350px); padding: 14px; grid-template-columns: minmax(0, 1fr); border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); display: grid; gap: 8px; }
.sd-whk__h { display: flex; justify-content: space-between; align-items: center; }
.sd-whk__h b { font-size: 15px; }
.sd-whk__h button { height: 30px; padding: 0 12px; border-radius: 8px; font-size: 12px; font-weight: 600; box-shadow: inset 0 0 0 1px var(--line); }
.sd-whk__e { border-radius: 12px; box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; }
.sd-whk__e[data-off] { opacity: 0.6; }
.sd-whk__row { display: flex; align-items: center; gap: 10px; padding: 10px 12px; }
.sd-whk__row > button:first-child { flex: 1; min-width: 0; text-align: left; }
.sd-whk__row code { display: block; font: 12px ui-monospace, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sd-whk__dots { display: flex; gap: 3px; margin-top: 5px; }
.sd-whk__dots i { width: 7px; height: 7px; border-radius: 50%; background: var(--fg); }
.sd-whk__dots i[data-fail] { background: transparent; box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-whk__dots i[data-new] { animation: sd-whk-p 400ms ease-out; }
@keyframes sd-whk-p { 0% { transform: scale(2); } }
.sd-whk__dots small { margin-left: 6px; font-size: 11px; color: var(--mute); line-height: 7px; }
.sd-whk__sw { position: relative; width: 36px; height: 21px; border-radius: 99px; background: var(--line) !important; flex: none; }
.sd-whk__sw::after { content: ''; position: absolute; top: 3px; left: 3px; width: 15px; height: 15px; border-radius: 50%; background: var(--fg); transition: transform 160ms; }
.sd-whk__sw[aria-checked='true'] { background: var(--fg) !important; }
.sd-whk__sw[aria-checked='true']::after { transform: translateX(15px); background: var(--bg); }
.sd-whk__d { padding: 0 12px 12px; display: grid; gap: 8px; font-size: 12px; animation: sd-whk-in 180ms ease-out; }
@keyframes sd-whk-in { from { opacity: 0; } }
.sd-whk__ev { display: flex; flex-wrap: wrap; gap: 4px; }
.sd-whk__ev span { padding: 2px 7px; border-radius: 6px; background: var(--soft); font: 11px ui-monospace, monospace; }
.sd-whk__sec { display: flex; align-items: center; gap: 6px; padding: 6px 6px 6px 10px; border-radius: 8px; background: var(--soft); }
.sd-whk__sec code { flex: 1; min-width: 0; font: 11.5px ui-monospace, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sd-whk__sec button, .sd-whk__act button { height: 26px; padding: 0 9px; border-radius: 7px; font-size: 11.5px; font-weight: 600; box-shadow: inset 0 0 0 1px var(--line); background: var(--bg) !important; }
.sd-whk__act { display: flex; gap: 6px; align-items: center; }
.sd-whk__act span { flex: 1; color: var(--mute); }
.sd-whk__act button[data-pri] { background: var(--strong) !important; color: var(--on) !important; box-shadow: none; }
.sd-whk__act button:disabled { opacity: 0.4; }
@media (prefers-reduced-motion: reduce) { .sd-whk *, .sd-whk *::before, .sd-whk *::after { animation: none !important; transition: none !important; } }Overview
Each endpoint shows its URL, subscribed events and the last ten deliveries as a row of dots, filled for success and hollow for failure.
Send test runs a ping through your handler and adds the result to the dots. The signing secret stays masked until revealed.
When to use
- Developer dashboards, integration settings.
When not to use
- Full delivery logs: link to a log view.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/webhook-list.json$
npx shadcn@latest add https://sundaeui.dev/r/webhook-list.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/webhook-list.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/webhook-list.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy webhook-list.tsx and webhook-list.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { WebhookList } from '@/components/sundae/webhook-list'
<WebhookList />Props
| Prop | Type | Default |
|---|---|---|
endpoints*Endpoints. | { id: string; url: string; events: string[]; on: boolean; history: boolean[] }[] | — |
pingTest delivery. | (id: string) => Promise<boolean> | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Delivery history is summarised as text ("8 of 10 succeeded").
- Enable switches and actions are labelled with the URL.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-whk; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Webhook Endpoints free?
- Yes. Webhook Endpoints is free for personal and commercial projects, with full source on this page.
- How do I install Webhook Endpoints?
- Run npx shadcn@latest add https://sundaeui.dev/r/webhook-list.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Webhook Endpoints support keyboard and screen readers?
- Delivery history is summarised as text ("8 of 10 succeeded"). Enable switches and actions are labelled with the URL.
- Does Webhook Endpoints respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →API Key Row
Components · RefinedMini Terminal
Components · RefinedIcon Picker
Components · RefinedToast Stack
Components · Refined- Pro
Sheet Drawer
Components · Refined Avatar Stack
Components · Refined