Webhooks Manager
A four-state webhook endpoint manager — event multi-select, reveal-and-rotate signing secrets with the overlap window stated, test sends that explain when they are blocked, and per-endpoint deliveries with retry.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/webhooks-manager.jsonPrompt
Build a React + TypeScript + Tailwind "WebhooksManager" block (lucide-react,
zod, and the shadcn Button / Input / Badge / Dialog / AlertDialog primitives).
It manages the endpoints themselves — not a read-only delivery log.
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
now: ISO instant (injected — never call new Date() in render);
endpoints: { id, url, events: string[], status: "active" | "paused" |
"failing" | "disabled", secretMasked, lastDelivery?: { at, code|null },
previousSecretExpiresAt?, consecutiveFailures?, disabledAt? }[];
availableEvents: { id, description? }[];
deliveries: { id, endpointId, event, code|null, ms, at, attempt }[];
secretOverlapHours: number; // your policy, stated as data
autoDisableAfterFailures?: number } // omit and the panel claims no limit
- Props = z.infer of that schema plus onSave / onRevealSecret / onRotateSecret /
onSendTest / onRetryDelivery / onSetStatus / onRetry, all optional: an
affordance whose callback is missing is not rendered at all.
- Export the URL checker from the contract so the schema and the editor agree:
it rejects non-https, embedded credentials, "#fragment", and hosts that can
never be reached from the internet (localhost, .local/.internal/.home.arpa,
::1, fc00::/7, fe80::/10, and the IPv4 reserved ranges 0/8, 10/8,
100.64/10, 127/8, 169.254/16, 172.16/12, 192.168/16) — each with the reason
in words. IPv4-mapped IPv6 goes through the IPv4 rules rather than past them:
new URL turns [::ffff:127.0.0.1] into [::ffff:7f00:1], so unwrap the ::ffff:
prefix in both the dotted and the hex-pair spelling before judging the host.
On success it returns the normalised URL.
Behavior
- Four first-class branches: loading (skeleton rows + sr-only status), empty
(explains the model and keeps Add endpoint working — its prose names an
action only when the matching callback exists, so a read-only card is not
told to add anything), error (nothing was changed, optional Try again, adding
withheld because the visible list is not the truth), ready (the table).
- The table carries URL, subscribed events, status and last delivery. Below the
container's @lg width the last three columns are dropped and their facts move
into the URL cell, so a row is self-sufficient at ~420px. "Manage" expands one
row at a time (aria-expanded / aria-controls).
- Expanded row: Edit, Pause / Resume / Re-enable, the signing secret, the test
sender, and that endpoint's recent deliveries.
- Signing secret: masked from the data; Reveal fetches the full value through a
callback and keeps it in state only while the row is open — collapsing drops
it. Copy falls back to selecting the text when the Clipboard API refuses.
A copy failure is tied to the value it describes, so hiding or rotating the
secret takes the message with it instead of leaving "the secret is selected"
over a masked box.
Rotate goes through a confirmation that states the consequence first: the
current secret keeps verifying for secretOverlapHours more hours and then
stops. While an overlap is open, show the server's exact expiry instant plus
how long is left.
- Every blocked action explains its blocker instead of greying out silently: no
events selected, endpoint paused, endpoint auto-disabled. An auto-disabled
endpoint states when it was stopped, after how many consecutive failures, and
the limit — only when the data carries one.
- Deliveries: outcome classes derived from the code, with null (timeout /
refused / TLS) kept separate from any status code. The mix line uses
largest-remainder apportionment so the shares total exactly 100. Retry is
offered on failed attempts only, and its confirmation says the endpoint will
receive a second copy — plus whether repeating is worth it at all (a 4xx that
is not 408/429 will be refused identically).
- Editor dialog: URL field plus an event multi-select grouped by the id prefix
with per-group select-all and a live "N of M selected" count. Events the
endpoint is subscribed to but the catalogue no longer offers get their own
group, so an edit cannot silently unsubscribe them. Refusals are inline and
named; focus returns to the trigger on close.
Rendering & styling
- Semantic tokens only: bg-card, bg-muted/40, text-muted-foreground, border,
primary for success, destructive for failure. Colour is never the only
channel — every state also carries an icon and a word.
- @container, not viewport breakpoints: this panel is a full-width settings page
in one product and a 420px drawer in another.
- cn() merges className; the root spreads the remaining props and forwards ref.
- Spinners and the skeleton pulse carry motion-reduce:animate-none; the chevron
rotation carries motion-reduce:transition-none. Async work is guarded by a ref
so a double click cannot send two test events, and a mounted flag stops a late
resolution from setting state after unmount. That guard is global — one action
at a time — so while one is in flight the row's other action buttons are
disabled instead of swallowing the click, and a rejection is reported under
the endpoint whose action raised it, never inherited by the next row opened.
Customization levers
- Column set: the three @lg-only columns are independent — drop "Events" or add
a description column without touching the expanded panel.
- Density: CELL/HEAD_CELL padding constants control the whole table; switch the
single-open accordion to a Set of open ids for multi-open.
- Status vocabulary: STATUS_META maps status → icon + label + token classes;
rename "Auto-disabled" or add a "pending verification" state there only.
- Outcome classes: OUTCOME_META and classifyCode decide the delivery taxonomy —
split 2xx into 200/202, or fold redirects into rejected, in one place.
- Secret handling: pass no onRevealSecret for a panel that only ever shows the
mask; drop onRotateSecret for products where rotation is CLI-only.
- Depth: the expanded panel's four sections are siblings — remove the test
sender or the deliveries list without changing the table.Concepts
- Reveal on demand, not on load — the masked fingerprint lives in the data; the full signing secret is fetched by a callback and held only while its row is open, so it never sits in a payload, a cache or a collapsed DOM node waiting to be inspected.
- Rotation overlap — a signing secret cannot be swapped atomically across a fleet, so both secrets verify for a stated window. The window is data (
secretOverlapHours), the deadline is the server's instant, and the confirmation says what breaks after it rather than promising anything the component cannot know. - Blocked action explains itself — a test send that cannot happen names the reason (no events selected, paused, auto-disabled) and points at the fix. A greyed-out button with no explanation is the same dead end as no button at all. The one moment a button does grey out is while another action in the same row is in flight — the spinner on that one is the explanation, and disabling the rest beats dropping their clicks in silence.
- Retry worth — an outcome class decides whether repeating the identical request can end differently: 5xx, timeout and 408/429 can, a plain 4xx cannot. The confirmation says which one this is before it sends a second copy.
- Largest remainder — the outcome mix under a delivery list is apportioned with the Hare–Niemeyer method, so three equal thirds read 34/33/33 and not 33/33/33; a percentage row that fails to total 100 quietly destroys trust in every other number on the card.
- Auto-disable threshold — the failing and disabled states are the provider's verdict, not the owner's, so they get their own words, their own counts, and a re-enable path that warns the same failure will stop it again.
Danger Zone
A settings-page danger zone — one row per irreversible action with its consequences itemised, a type-the-resource-name gate, grace windows dated from the payload's own clock, and blocked actions that name their blocker instead of a dead button.
Data Export Request
A GDPR-style data export block — pick scopes against live size estimates, confirm a request that states what the archive contains and who can open it, watch a build that never dresses a queue position up as progress, and download a link that visibly counts down to its own expiry.