Display

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  AlertCircle,
  Ban,
  ChevronRight,
  CircleAlert,
  CircleCheck,
  Clock,
  CornerUpRight,
  Loader2,
  Lock,
  RefreshCw,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/webhook-log.json

Prompt

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 outcomestimeout and connection are their own arms of the union and print no number. A console that renders them as 0 or 500 invents 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, with now injected 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 title attribute. 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.

On This Page