{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "webhook-card",
  "title": "Webhook Card",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A webhook endpoint card with URL and enable switch, health from recent deliveries, a masked signing secret, subscribed events and expandable deliveries with payloads and redeliver.",
  "dependencies": [
    "motion@^12",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/copy-button.json",
    "https://ui.ballmac.com/r/switch.json",
    "https://ui.ballmac.com/r/highlight.json",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/webhook-card.tsx",
      "content": "// Ballmac UI: Webhook Card. https://ui.ballmac.com/components/webhook-card\n\"use client\"\n\nimport * as React from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { Check, ChevronRight, Eye, EyeOff, KeyRound, RotateCw, Webhook, X } from \"lucide-react\"\n\nimport { CopyButton } from \"@/components/ballmac/copy-button\"\nimport { Switch } from \"@/components/ballmac/switch\"\nimport { highlightLines, tokenClass } from \"@/lib/ballmac/highlight\"\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype WebhookDelivery = {\n  /** Unique id of the delivery. */\n  id: string\n  /** Event name, such as \"invoice.paid\". */\n  event: string\n  /** HTTP status your endpoint returned. Use 0 for no response (timeout or connection error). */\n  status: number\n  /** Time your endpoint took, in milliseconds. */\n  duration?: number\n  /** When it was sent, as an ISO string. Shown in UTC. */\n  time: string\n  /** Request body that was sent. */\n  payload?: string\n  /** Response body your endpoint returned. */\n  response?: string\n}\n\nconst MONTHS = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"]\n\nfunction stamp(iso: string) {\n  const d = new Date(iso)\n  if (Number.isNaN(d.getTime())) return iso\n  const p = (n: number) => String(n).padStart(2, \"0\")\n  return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())} UTC`\n}\n\nconst ok = (status: number) => status >= 200 && status < 300\n\nfunction statusText(status: number) {\n  if (status === 0) return \"No response\"\n  return ok(status) ? \"Delivered\" : status >= 500 ? \"Server error\" : status >= 400 ? \"Rejected\" : \"Redirected\"\n}\n\nfunction Json({ code, label }: { code: string; label: string }) {\n  const lines = React.useMemo(() => highlightLines(code, \"json\"), [code])\n  return (\n    <pre dir=\"ltr\"\n      role=\"region\"\n      aria-label={label}\n      tabIndex={0}\n      className=\"max-h-48 overflow-auto rounded-md border bg-muted/40 p-3 font-mono text-xs leading-5 text-foreground outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n    >\n      <code className=\"grid\">\n        {lines.map((line, i) => (\n          <span key={i} className=\"whitespace-pre\">\n            {line.length === 0 ? \" \" : line.map((t, j) => <span key={j} className={tokenClass[t.type]}>{t.text}</span>)}\n          </span>\n        ))}\n      </code>\n    </pre>\n  )\n}\n\ntype WebhookCardProps = Omit<React.ComponentProps<\"section\">, \"children\"> & {\n  /** Endpoint URL that receives events. */\n  url: string\n  /** Whether deliveries are sent. */\n  enabled?: boolean\n  /** Called when the switch is flipped. */\n  onEnabledChange?: (enabled: boolean) => void\n  /** Signing secret. Masked until revealed. */\n  secret?: string\n  /** Events this endpoint subscribes to. */\n  events?: string[]\n  /** How many event names show before \"+N\". */\n  visibleEvents?: number\n  /** Recent deliveries, newest first. */\n  deliveries?: WebhookDelivery[]\n  /** Adds a Redeliver button to each opened delivery. */\n  onRedeliver?: (delivery: WebhookDelivery) => void\n  /** Adds a \"Send test event\" button. */\n  onTest?: () => void\n}\n\nfunction WebhookCard({\n  url,\n  enabled = true,\n  onEnabledChange,\n  secret,\n  events = [],\n  visibleEvents = 3,\n  deliveries = [],\n  onRedeliver,\n  onTest,\n  className,\n  ...props\n}: WebhookCardProps) {\n  const msg = useMessages()\n  const reduce = useReducedMotion()\n  const uid = React.useId()\n  const [isOn, setIsOn] = React.useState(enabled)\n  React.useEffect(() => setIsOn(enabled), [enabled])\n  const [reveal, setReveal] = React.useState(false)\n  const [open, setOpen] = React.useState<string | null>(null)\n  const recent = deliveries.slice(0, 5)\n  const failing = recent.length > 0 && recent.filter((d) => !ok(d.status)).length >= Math.ceil(recent.length / 2)\n  const state = !isOn ? \"disabled\" : failing ? \"failing\" : \"active\"\n  const meta = {\n    active: { label: \"Active\", icon: Check, chip: \"border-chart-2/30 bg-chart-2/10\" },\n    failing: { label: \"Failing\", icon: X, chip: \"border-destructive/30 bg-destructive/10\" },\n    disabled: { label: \"Disabled\", icon: X, chip: \"border-border bg-muted\" },\n  }[state]\n  const Icon = meta.icon\n  const shownEvents = events.slice(0, visibleEvents)\n  const extra = events.length - shownEvents.length\n  const masked = secret ? `${secret.slice(0, Math.min(6, Math.max(secret.length - 4, 0)))}${\"•\".repeat(14)}` : \"\"\n  const successRate = deliveries.length ? Math.round((deliveries.filter((d) => ok(d.status)).length / deliveries.length) * 100) : null\n\n  return (\n    <section\n      data-slot=\"webhook-card\"\n      data-state={state}\n      aria-label={msg(\"webhook-card.webhook\", \"Webhook {url}\", { url })}\n      className={cn(\"w-full overflow-hidden rounded-xl border bg-card text-card-foreground shadow-xs\", className)}\n      {...props}\n    >\n      <header className=\"flex items-start gap-3 p-4\">\n        <span aria-hidden=\"true\" className=\"flex size-10 shrink-0 items-center justify-center rounded-lg border bg-muted\">\n          <Webhook className=\"size-5\" />\n        </span>\n        <div className=\"min-w-0 flex-1\">\n          <div className=\"flex min-w-0 items-center gap-1\">\n            <code className=\"min-w-0 truncate font-mono text-[13px] font-medium text-foreground\" title={url}>\n              {url}\n            </code>\n            <CopyButton size=\"sm\" value={url} ariaLabel=\"Copy endpoint URL\" />\n          </div>\n          <div className=\"mt-1 flex flex-wrap items-center gap-x-3 gap-y-1.5\">\n            <span className={cn(\"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium text-foreground\", meta.chip)}>\n              <Icon aria-hidden=\"true\" className=\"size-3\" />\n              {meta.label}\n            </span>\n            {successRate !== null && (\n              <span className=\"text-xs text-muted-foreground tabular-nums\">{msg(\"webhook-card.successRate\", \"{percent}% of {count} deliveries succeeded\", { percent: successRate, count: deliveries.length })}</span>\n            )}\n          </div>\n        </div>\n        <label className=\"flex shrink-0 items-center gap-2 text-xs text-muted-foreground\">\n          <span className=\"sr-only\">{msg(\"webhook-card.sendEventsToThisEndpoint\", \"Send events to this endpoint\")}</span>\n          <Switch\n            checked={isOn}\n            onCheckedChange={(next) => {\n              setIsOn(next)\n              onEnabledChange?.(next)\n            }}\n            aria-label={msg(\"webhook-card.sendEventsToThisEndpoint\", \"Send events to this endpoint\")}\n          />\n        </label>\n      </header>\n\n      {(secret || events.length > 0) && (\n        <div className=\"grid gap-3 border-t px-4 py-3 text-[13px]\">\n          {secret && (\n            <div className=\"flex items-center gap-2\">\n              <KeyRound aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />\n              <span className=\"shrink-0 text-muted-foreground\">{msg(\"webhook-card.signingSecret\", \"Signing secret\")}</span>\n              <code className=\"min-w-0 flex-1 truncate font-mono text-xs text-foreground\">{reveal ? secret : masked}</code>\n              <button\n                type=\"button\"\n                aria-pressed={reveal}\n                aria-label={reveal ? msg(\"webhook-card.hideSigningSecret\", \"Hide signing secret\") : msg(\"webhook-card.revealSigningSecret\", \"Reveal signing secret\")}\n                onClick={() => setReveal((r) => !r)}\n                className=\"inline-flex size-7 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n              >\n                {reveal ? <EyeOff aria-hidden=\"true\" className=\"size-3.5\" /> : <Eye aria-hidden=\"true\" className=\"size-3.5\" />}\n              </button>\n              <CopyButton size=\"sm\" value={secret} ariaLabel=\"Copy signing secret\" />\n            </div>\n          )}\n          {events.length > 0 && (\n            <div className=\"flex flex-wrap items-center gap-1.5\">\n              <span className=\"me-1 text-muted-foreground\">{msg(\"webhook-card.events\", \"Events\")}</span>\n              {shownEvents.map((e) => (\n                <code key={e} className=\"rounded-md border bg-muted/60 px-1.5 py-0.5 font-mono text-[11px] text-foreground\">\n                  {e}\n                </code>\n              ))}\n              {extra > 0 && (\n                <span className=\"text-xs text-muted-foreground\" title={events.slice(visibleEvents).join(\", \")}>\n                  {msg(\"webhook-card.moreEvents\", \"+{count} more\", { count: extra })}\n                </span>\n              )}\n            </div>\n          )}\n        </div>\n      )}\n\n      <div className=\"border-t\">\n        <div className=\"flex items-center justify-between gap-2 px-4 py-2.5\">\n          <h4 className=\"text-xs font-semibold tracking-wide text-muted-foreground uppercase\">{msg(\"webhook-card.recentDeliveries\", \"Recent deliveries\")}</h4>\n          {onTest && (\n            <button\n              type=\"button\"\n              onClick={onTest}\n              className=\"h-7 rounded-md border bg-background px-2.5 text-xs font-medium shadow-xs outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n            >\n              {msg(\"webhook-card.sendTestEvent\", \"Send test event\")}\n            </button>\n          )}\n        </div>\n        {deliveries.length === 0 ? (\n          <p className=\"px-4 pb-5 text-[13px] text-muted-foreground\">{msg(\"webhook-card.noDeliveriesYetEventsAppear\", \"No deliveries yet. Events appear here as soon as they are sent.\")}</p>\n        ) : (\n          <ul className=\"divide-y border-t\">\n            {deliveries.map((d) => {\n              const isOpen = open === d.id\n              const panel = `${uid}-${d.id}`\n              return (\n                <li key={d.id}>\n                  <button\n                    type=\"button\"\n                    aria-expanded={isOpen}\n                    aria-controls={panel}\n                    onClick={() => setOpen(isOpen ? null : d.id)}\n                    className=\"flex w-full items-center gap-3 px-4 py-2.5 text-start outline-none transition-colors hover:bg-accent/40 focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50 motion-reduce:transition-none\"\n                  >\n                    <ChevronRight\n                      aria-hidden=\"true\"\n                      className={cn(\"size-4 shrink-0 text-muted-foreground transition-transform duration-200 motion-reduce:transition-none\", isOpen ? \"rotate-90\" : \"rtl:rotate-180\")}\n                    />\n                    <span\n                      className={cn(\n                        \"inline-flex h-6 min-w-[3.25rem] shrink-0 items-center justify-center gap-1 rounded-md border px-1.5 font-mono text-xs font-semibold text-foreground tabular-nums\",\n                        ok(d.status) ? \"border-chart-2/40 bg-chart-2/10\" : \"border-destructive/40 bg-destructive/10\"\n                      )}\n                    >\n                      {d.status || \"—\"}\n                      <span className=\"sr-only\">, {statusText(d.status)}</span>\n                    </span>\n                    <span className=\"min-w-0 flex-1\">\n                      <span className=\"block truncate font-mono text-[13px] text-foreground\">{d.event}</span>\n                      <span className=\"block truncate text-xs text-muted-foreground\">\n                        {stamp(d.time)}\n                        {d.duration !== undefined && ` · ${d.duration} ms`}\n                      </span>\n                    </span>\n                    {!ok(d.status) && <span className=\"hidden text-xs text-foreground sm:block\">{statusText(d.status)}</span>}\n                  </button>\n                  <AnimatePresence initial={false}>\n                    {isOpen && (\n                      <motion.div\n                        id={panel}\n                        initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}\n                        animate={reduce ? { opacity: 1 } : { height: \"auto\", opacity: 1 }}\n                        exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}\n                        transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}\n                        className=\"overflow-hidden\"\n                      >\n                        <div className=\"grid gap-3 border-t bg-muted/20 px-4 py-3 sm:ps-11\">\n                          {d.payload && (\n                            <div className=\"grid gap-1.5\">\n                              <p className=\"text-xs font-medium text-muted-foreground\">{msg(\"webhook-card.requestBody\", \"Request body\")}</p>\n                              <Json code={d.payload} label={`Request body of ${d.event}`} />\n                            </div>\n                          )}\n                          {d.response && (\n                            <div className=\"grid gap-1.5\">\n                              <p className=\"text-xs font-medium text-muted-foreground\">{msg(\"webhook-card.yourResponse\", \"Your response\")}</p>\n                              <Json code={d.response} label={`Response to ${d.event}`} />\n                            </div>\n                          )}\n                          {onRedeliver && (\n                            <button\n                              type=\"button\"\n                              onClick={() => onRedeliver(d)}\n                              className=\"inline-flex h-8 w-fit items-center gap-1.5 rounded-md border bg-background px-3 text-[13px] font-medium shadow-xs outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n                            >\n                              <RotateCw aria-hidden=\"true\" className=\"size-3.5\" />\n                              {msg(\"webhook-card.redeliver\", \"Redeliver\")}\n                            </button>\n                          )}\n                        </div>\n                      </motion.div>\n                    )}\n                  </AnimatePresence>\n                </li>\n              )\n            })}\n          </ul>\n        )}\n      </div>\n    </section>\n  )\n}\n\nexport { WebhookCard, type WebhookCardProps, type WebhookDelivery }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/webhook-card.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "webhook",
      "endpoint",
      "events",
      "deliveries",
      "integration"
    ],
    "version": "1.0.0",
    "updated": "2026-10-01",
    "ai": {
      "summary": "url, enabled/onEnabledChange, secret, events, deliveries [{ id, event, status, duration, time, payload, response }], onRedeliver, onTest. Health is Active, Failing (half or more of the last five failed) or Disabled, shown with icon and word.",
      "whenToUse": [
        "Developer dashboards that manage outgoing webhooks",
        "Debugging failed deliveries"
      ],
      "whenNotToUse": [
        "Incoming webhook documentation (api-endpoint)"
      ],
      "composesWith": [
        "api-key-field",
        "log-stream",
        "status-badge-row"
      ],
      "a11y": [
        {
          "keys": "Space",
          "action": "Toggles the endpoint"
        },
        {
          "keys": "Enter / Space",
          "action": "Expands a delivery"
        },
        {
          "keys": "Screen readers",
          "action": "Each status code reads with its meaning ('500, Server error'); payloads are labelled regions"
        }
      ],
      "customization": [
        "visibleEvents",
        "onRedeliver",
        "onTest",
        "deliveries"
      ]
    },
    "examples": [
      "webhook-card-demo",
      "webhook-card-failing"
    ],
    "url": "https://ui.ballmac.com/components/webhook-card"
  },
  "categories": [
    "developer"
  ],
  "type": "registry:ui"
}