import { useCallback, useEffect, useMemo, useState } from "react" import { CheckCircle2, Clock, Copy, History, KeyRound, Pause, Play, Plus, RefreshCw, RotateCw, Send, Trash2, Webhook as WebhookIcon, } from "lucide-react" import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client" import { ActionsCell, BadgeCell, DataTable, DateCell, Pagination, useTable, type ActionItem, type BadgeTone, type Column, } from "@crema/table-ui" import { SearchInput } from "@crema/search-ui" import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui" import { AppShell } from "~/components/layout/app-shell" import { Badge } from "~/components/ui/badge" import { Button } from "~/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "~/components/ui/card" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "~/components/ui/dialog" import { Input } from "~/components/ui/input" import { Label } from "~/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "~/components/ui/select" import { Textarea } from "~/components/ui/textarea" import { COMMON_WEBHOOK_EVENTS, createWebhook, deleteWebhook, listWebhookDeliveries, listWebhooks, pauseWebhook, regenerateWebhookSecret, resumeWebhook, testWebhook, updateWebhook, type Webhook, type WebhookDelivery, type WebhookInput, type WebhookRetryStrategy, type WebhookStatus, } from "~/lib/arcadia/webhooks" import { pageTitle } from "~/lib/page-meta" import { useSession } from "~/lib/session" import { useRegisterContext } from "@crema/aifirst-ui/context" export const meta = () => pageTitle("Webhooks") type EditorState = | { mode: "create" } | { mode: "edit"; webhook: Webhook } | null export default function WebhooksRoute() { const session = useSession() const arcadia = useArcadiaClient() const [webhooks, setWebhooks] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [info, setInfo] = useState(null) const [search, setSearch] = useState("") const [editor, setEditor] = useState(null) const [pendingDelete, setPendingDelete] = useState(null) const [deliveriesFor, setDeliveriesFor] = useState(null) const [revealedSecret, setRevealedSecret] = useState<{ webhookId: string secret: string isNew?: boolean } | null>(null) const refresh = useCallback(async () => { setError(null) setLoading(true) try { setWebhooks(await listWebhooks(arcadia)) } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Failed to load webhooks.") } finally { setLoading(false) } }, [arcadia]) useEffect(() => { if (session) refresh() }, [session, refresh]) const columns = useMemo[]>( () => [ { id: "url", header: "URL", accessor: "url", sortable: true, cell: (w) => (
{w.url} {w.description ? ( {w.description} ) : null}
), }, { id: "status", header: "Status", accessor: "status", sortable: true, cell: (w) => , }, { id: "events", header: "Events", cell: (w) => w.events.length === 0 ? ( all ) : ( {w.events.length} ), }, { id: "success", header: "Success", accessor: "success_count", sortable: true, cell: (w) => {w.success_count}, }, { id: "failure", header: "Failure", accessor: "failure_count", sortable: true, cell: (w) => ( 0 ? "text-destructive" : "text-muted-foreground" }`} > {w.failure_count} ), }, { id: "last", header: "Last triggered", accessor: "last_triggered_at", sortable: true, cell: (w) => w.last_triggered_at ? ( ) : ( never ), }, { id: "actions", header: "", align: "right", cell: (w) => ( ), }, ], [arcadia, refresh], ) const summary = useMemo( () => ({ total: webhooks.length, byStatus: countBy(webhooks, (w) => w.status), total_failures: webhooks.reduce((a, w) => a + w.failure_count, 0), total_successes: webhooks.reduce((a, w) => a + w.success_count, 0), webhooks: webhooks.map((w) => ({ url: w.url, status: w.status, events: w.events, success_count: w.success_count, failure_count: w.failure_count, last_triggered_at: w.last_triggered_at, })), }), [webhooks], ) useRegisterContext("webhooks", summary) const table = useTable({ data: webhooks, columns, getRowId: (w) => w.id, initialPageSize: 25, initialSearch: search, }) useEffect(() => { table.setSearch(search) }, [search, table]) return (

Webhooks

Outbound HTTP callbacks for platform events. Each delivery is signed with the endpoint's secret.

{error ? ( setError(null)}> {error} ) : null} {info ? ( setInfo(null)}> {info} ) : null}
{table.total} of {webhooks.length}
{table.total === 0 && !loading ? ( } title={search ? "No webhooks match." : "No webhooks yet."} description={ search ? "Try a different search." : "Add an endpoint to receive event notifications from arcadia." } className="py-12" /> ) : ( <> w.id} sort={table.sort} onSortToggle={table.toggleSort} loading={loading && webhooks.length > 0} stickyHeader /> )}
!o && setPendingDelete(null)} title="Delete webhook?" description={ pendingDelete ? `${pendingDelete.url} will stop receiving events. Pending retries are abandoned.` : "" } confirmLabel="Delete" variant="danger" onConfirm={async () => { if (!pendingDelete) return try { await deleteWebhook(arcadia, pendingDelete.id) setPendingDelete(null) setInfo("Webhook deleted.") await refresh() } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Delete failed.") setPendingDelete(null) } }} /> setEditor(null)} onSaved={async (created) => { setEditor(null) if (created?.secret) { setRevealedSecret({ webhookId: created.id, secret: created.secret, isNew: true }) } await refresh() }} onError={setError} /> setDeliveriesFor(null)} onError={setError} /> setRevealedSecret(null)} />
) } function statusTone(s: WebhookStatus): BadgeTone { if (s === "active") return "success" if (s === "paused") return "warning" return "default" } function rowActions( w: Webhook, ctx: { arcadia: ReturnType refresh: () => Promise setEditor: (s: EditorState) => void setPendingDelete: (w: Webhook | null) => void setDeliveriesFor: (w: Webhook | null) => void setRevealedSecret: ( r: { webhookId: string; secret: string; isNew?: boolean } | null, ) => void setError: (m: string | null) => void setInfo: (m: string | null) => void }, ): ActionItem[] { const { arcadia, refresh, setEditor, setPendingDelete, setDeliveriesFor, setRevealedSecret, setError, setInfo, } = ctx const items: ActionItem[] = [] items.push({ id: "edit", label: "Edit", dataAction: `webhook-${w.id}-edit`, onSelect: () => setEditor({ mode: "edit", webhook: w }), }) items.push({ id: "deliveries", label: "View deliveries", icon: , dataAction: `webhook-${w.id}-deliveries`, onSelect: () => setDeliveriesFor(w), }) items.push({ id: "test", label: "Send test event", icon: , dataAction: `webhook-${w.id}-test`, onSelect: async () => { try { const r = await testWebhook(arcadia, w.id) setInfo(r.ok === false ? r.message ?? "Test failed." : "Test event sent.") } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Test failed.") } }, }) if (w.status === "active") { items.push({ id: "pause", label: "Pause", icon: , dataAction: `webhook-${w.id}-pause`, onSelect: async () => { try { await pauseWebhook(arcadia, w.id) setInfo("Webhook paused.") await refresh() } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Pause failed.") } }, }) } else { items.push({ id: "resume", label: "Resume", icon: , dataAction: `webhook-${w.id}-resume`, onSelect: async () => { try { await resumeWebhook(arcadia, w.id) setInfo("Webhook resumed.") await refresh() } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Resume failed.") } }, }) } items.push({ id: "regen-secret", label: "Regenerate secret", icon: , dataAction: `webhook-${w.id}-regen-secret`, onSelect: async () => { try { const updated = await regenerateWebhookSecret(arcadia, w.id) if (updated.secret) { setRevealedSecret({ webhookId: updated.id, secret: updated.secret }) } await refresh() } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Regenerate failed.") } }, }) items.push({ id: "delete", label: "Delete", icon: , destructive: true, dataAction: `webhook-${w.id}-delete`, onSelect: () => setPendingDelete(w), }) return items } function WebhookEditorDialog({ state, onClose, onSaved, onError, }: { state: EditorState onClose: () => void onSaved: (created?: Webhook) => Promise onError: (msg: string | null) => void }) { const arcadia = useArcadiaClient() const open = state !== null const isEdit = state?.mode === "edit" const initial = isEdit ? state.webhook : null const [url, setUrl] = useState("") const [description, setDescription] = useState("") const [eventsText, setEventsText] = useState("") const [headersText, setHeadersText] = useState("") const [maxRetries, setMaxRetries] = useState("3") const [retryStrategy, setRetryStrategy] = useState("exponential") const [saving, setSaving] = useState(false) useEffect(() => { if (!open) return if (initial) { setUrl(initial.url) setDescription(initial.description ?? "") setEventsText(initial.events.join("\n")) setHeadersText( Object.entries(initial.headers ?? {}) .map(([k, v]) => `${k}: ${v}`) .join("\n"), ) setMaxRetries(String(initial.max_retries)) setRetryStrategy(initial.retry_strategy) } else { setUrl("") setDescription("") setEventsText("") setHeadersText("") setMaxRetries("3") setRetryStrategy("exponential") } }, [open, initial]) const submit = async () => { onError(null) setSaving(true) try { const events = eventsText .split(/\r?\n/) .map((s) => s.trim()) .filter(Boolean) const headers: Record = {} for (const line of headersText.split(/\r?\n/)) { const idx = line.indexOf(":") if (idx <= 0) continue const k = line.slice(0, idx).trim() const v = line.slice(idx + 1).trim() if (k) headers[k] = v } const input: WebhookInput = { url, description: description || null, events, headers, max_retries: Math.max(0, Number(maxRetries) || 0), retry_strategy: retryStrategy, } if (isEdit && initial) { const updated = await updateWebhook(arcadia, initial.id, input) await onSaved(updated) } else { const created = await createWebhook(arcadia, input) await onSaved(created) } } catch (err) { onError( err instanceof ArcadiaError ? err.message : err instanceof Error ? err.message : "Save failed.", ) } finally { setSaving(false) } } return ( !o && onClose()}> {isEdit ? "Edit webhook" : "New webhook"} {isEdit ? "Update the destination and event filter." : "Arcadia POSTs JSON payloads to this URL when the listed events fire."}
setUrl(e.target.value)} placeholder="https://example.com/webhooks/arcadia" data-action="webhook-form-url" />
setDescription(e.target.value)} data-action="webhook-form-description" />