Webhook Log
A webhook delivery log with the retry lifecycle built in — outcome classes that keep timeouts separate from status codes, a countdown to the next attempt derived from an injected clock, masked credential headers, collapsed payloads with honest truncation, and a guarded replay confirmation.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/webhook-log.jsonPrompt
Build a React + TypeScript + Tailwind "WebhookLog" component with zod and
lucide-react. It reuses a JSON tree viewer for parseable payloads (this repo's
`json-viewer`); swap in your own or keep only the text renderer below.
Contract
- A sibling contract file is the single source of truth. One delivery is
{ id, event, eventId?, url, method?: "POST"|"PUT"|"PATCH"|"GET",
maxAttempts, attempts: Attempt[] (>= 1, oldest first), nextAttemptAt?,
request?: Payload, response?: Payload }.
Attempt = { number, startedAt (ISO with offset), durationMs: number | null,
outcome }. Payload = { headers?, body?, contentType?, bodyBytes? }.
- `outcome` is a DISCRIMINATED UNION, not a status code:
{ kind: "http", statusCode } | { kind: "timeout", limitMs? } |
{ kind: "connection", reason: "dns"|"refused"|"reset"|"tls"|"unknown" } |
{ kind: "pending" }.
The two most common webhook failures — timed out, never connected — have no
status code at all. Encoding them as 0 or 500 is what makes a console claim
the endpoint answered when it never did, and it destroys the "is this worth
retrying?" signal. Only the "http" arm ever prints a number.
- One row is one DELIVERY (an event going to one endpoint), not one attempt.
Splitting attempts across rows is what makes a log unable to answer "is this
one still coming?". `response` is the most recent attempt's response only.
- A `superRefine` rejects two impossible shapes: a 2xx last attempt that still
schedules `nextAttemptAt`, and more attempts than `maxAttempts`. Every check
guards its own existence first — zod runs all refinements even after one
fails, so an unguarded `data.attempts[0].outcome.kind` would throw a
TypeError out of safeParse instead of returning { success: false }.
- Component props: deliveries; status ("loading"|"empty"|"error"|"ready");
now (string | number | Date); locale = "en-US"; timeZone = "UTC";
states? + onStatesChange? (controlled filter); onReplay?(delivery) =>
void | Promise; onReload?; sensitiveHeaders?: string[]; maxBodyLines? = 200
(clamped 20-2000); skeletonRows? = 5 (clamped 1-24); emptyState?; label?;
className. Affordances are handler-gated: no onReplay means no replay button
anywhere, no onReload means the error branch has no button.
Behavior
- Lifecycle state is DERIVED, never stored: last outcome pending -> "sending";
2xx -> "delivered"; otherwise `nextAttemptAt` present -> "retrying";
otherwise attempts >= maxAttempts -> "abandoned"; otherwise -> "failed".
"failed" (the queue decided not to retry, e.g. a 4xx) and "abandoned" (the
queue ran out of tries) stay apart because they need different follow-up.
- Outcome classes and their advice, deliberately NOT interchangeable: 2xx
delivered (no action); 3xx "redirect, not followed" (senders do not follow
redirects, so nothing was delivered and retrying hits the same redirect);
4xx "rejected by the endpoint" (re-sending the identical payload fails
identically — fix the route or handler, then replay) EXCEPT 408 and 429,
which mean "come back later" and are the one retryable 4xx family; 5xx
"endpoint error" (usually transient, retried with backoff); timeout and
connection failures print no code at all and say so in words.
- THE COUNTDOWN IS DERIVED FROM TWO ABSOLUTE INSTANTS: remaining =
Date.parse(nextAttemptAt) - now, recomputed every render in ONE helper that
is called exactly once per row. The component runs no timer of its own; the
host ticks `now` (setInterval 1s off Date.now()). A counter that subtracts
1000 per tick looks identical for a minute and then lies by however long the
tab was throttled — measured against a patched copy of this component: after
a 40 s suspension with a single tick delivered, the derived version showed
560000 ms against a truth of 560000 ms (drift 0) and the self-decrementing
version showed 599000 ms (drift 39 s), which it never recovers from.
- Credential headers are masked BEFORE they reach the DOM. A name in the
built-in list (authorization, cookie, set-cookie, x-api-key,
stripe-signature, svix-signature, ...) or containing
secret/token/signature/password/api-key/auth renders as a lock icon, the auth
scheme when it is a known one ("Bearer"), dots, and a character count. The
raw value is never rendered, never put in a title attribute and never merely
covered with CSS — a blur is a screenshot fix, not a leak fix. Verify with a
whole-document string search, not with your eyes.
- Replay is a duplicate-side-effect action, so it is two steps: the button
opens a confirm strip naming the event and the host and saying the endpoint
will receive a second copy; focus goes to the SAFE option (pressing Enter
right after a confirmation appears must not resend a payment webhook);
Escape cancels and returns focus to the trigger. The confirm button is
guarded by a Set held in a ref, not by state — five clicks are five separate
tasks and a state read inside the handler can still see the pre-update
value. It calls `new Promise(r => r(fn()))`, not `Promise.resolve(fn())`, so
a handler that throws synchronously becomes a rejection instead of leaving
the button at "Sending..." forever. The button stays mounted with
aria-disabled (a native `disabled` blurs it mid-action).
- Bodies are collapsed by default and never silently shortened. The toggle
states line count and size; expanded, a body that parses as JSON renders as
a tree and anything else as numbered lines capped at `maxBodyLines`, with
"Showing 200 of 5000 lines · 4800 hidden" plus a "Show 200 more" button; a
single line longer than 4000 chars is cut with a visible "+N characters"
marker; and when `bodyBytes` exceeds what arrived, the view says the API
stored only the first N of M. JSON.parse is deferred until a body is opened
and skipped above 200k chars, because it blocks the main thread.
- Filter chips carry live counts and really filter. Filtering everything away
produces different copy than never having had a delivery, plus a "Show all
states" way out.
- Four first-class branches on `status`: loading (skeleton rows with the real
row's geometry plus an sr-only role="status"), empty, error (the copy says
the LOG is unreachable, which is not the same as deliveries failing), ready.
Rendering & styling
- Semantic tokens only: bg-card, bg-muted / text-muted-foreground, bg-primary
and text-primary (delivered), bg-destructive / text-destructive (failed,
gave up, the confirm strip), border and border-dashed (gave up, sending),
focus-visible:ring-2 ring-ring on every control. Every state carries an icon
AND a word, so it survives a monochrome theme and a screen reader.
- Structure: a real ul/li list; each row is an h3 plus a disclosure button with
aria-expanded/aria-controls, and the panel's children are only rendered
while it is open (a `grid-rows-[0fr]` collapse would keep its buttons
Tab-reachable). Headers are real tables with `th scope="col"` and
`th scope="row"`, and the sr-only caption sits on the CAPTION element, never
on the table itself — `width: 1px` is only a lower bound for a table box and
it will push the page sideways.
- Long-string defence, measured rather than assumed: `wrap-anywhere` on the
URL, the event chip, header value cells and body lines is what carries the
weight (removing it from the URL alone made a 375px panel clip 87px of
content). All 20 `min-w-0` classes an earlier draft carried were deleted
after a build-per-variant matrix showed byte-identical layout at
375/768/1440 with and without them: `wrap-anywhere` already collapses
min-content, so `min-width: auto` never binds. Remember that before deleting
a `wrap-anywhere` — it is the load-bearing half of that pair.
- Rows expose `data-state` (lifecycle) and `data-outcome` (last outcome class)
as styling hooks.
- Density adapts through `@container`, not viewport breakpoints. The only
animations are the skeleton pulse, the chevron rotation and the spinner, all
carrying motion-reduce:*-none.
Customization levers
- Retry policy copy: `classifyOutcome` is exported and holds every verdict in
one place — change the 4xx/5xx wording, add 409 to the retryable set, or
return your provider's own backoff explanation without touching the render.
- Time formatting: `formatGap` yields "3m 12s" / "1h 04m"; switch it to an
absolute "next attempt at 09:44 UTC" if your audience prefers wall clocks.
`timeZone` defaults to UTC on purpose — pass the viewer's zone explicitly.
- Masking: extend `sensitiveHeaders`, or edit the built-in fragment list. It
fails closed (a header named X-Author is masked); loosen it only if that
matters more to you than a leak.
- Payload volume: `maxBodyLines` and the JSON parse ceiling decide how much DOM
one row can create; drop the JSON tree for a smaller install and keep the
numbered-line renderer.
- Row density: px-3 py-2.5 with three stacked lines reads as "comfortable";
move the URL line into the panel for a tighter list without touching the
state machine.
- Replay semantics: keep the strip and rewrite the copy per event type (money
movements deserve harsher wording), or omit `onReplay` for a read-only view.Concepts
- Delivery, not attempt — a row is one event going to one endpoint, carrying every try it has made. The retry lifecycle is a property of the delivery, so a per-attempt row list can never answer the only question that matters at 3am: is this one still coming?
- No-status-code outcomes —
timeoutandconnectionare their own arms of the union and print no number. A console that renders them as0or500invents an answer the endpoint never gave, and it merges "the server is broken" with "the server was never reached". - Retryable versus rewrite-required — 5xx and timeouts get "the queue will keep trying"; a 4xx gets "re-sending the identical payload fails identically, fix it and then replay". Same red row, opposite instruction. 408 and 429 are carved out as the retryable 4xx family.
- Countdown by subtraction, not decrement — remaining time is
nextAttemptAt − now, recomputed from two absolute instants every render, withnowinjected by the host. A self-decrementing counter drifts by exactly as long as the tab was throttled: measured at 39 s of lie after a 40 s suspension. - Mask before render — the credential never enters the document, so it cannot be recovered from devtools, a copied DOM node or a
titleattribute. A blur or a colour overlay hides it from a screenshot and from nobody else. - Two-step, single-shot replay — replaying re-sends a real payload, so it asks first, names the endpoint, focuses the safe button, and holds a ref-backed guard so five impatient clicks produce exactly one send.
Feature Flag List
A per-environment feature flag table — production toggles gated behind a confirmation that repeats the flag key, rollout percentage kept orthogonal to on/off, optimistic switches that roll back one at a time, and expandable targeting rules.
Citation List
The numbered reference list under an AI answer — numbering derived exactly the way Citation Chip derives it so the markers and the list can never disagree, repeated sources merged with a citation count, graded trust that never leans on colour, and sources with no usable URL that refuse to become dead links.