# Webhooks

The endpoints the app sends events to: each one's address, the events it gets, whether it's on, and its recent deliveries with their outcome, HTTP status and time taken. A test send shows its result where it was pressed. Adding an endpoint asks for an https address and its events (none is ticked for you). Removing asks first and says what stops; deliveries already made stay made. Keep it returns focus to the endpoint's Remove button; after a remove it goes to the Webhooks heading, since the endpoint's row goes. An endpoint is shown and named by its `label` when it has one, so a URL with a token in it (Slack, Discord) stays off the screen and out of screen readers.

- Registry name: `webhooks` (`@candor/webhooks`)
- Tier: Pro. Installing it needs a Pro, Premium or Team licence key: the person runs `npx candor-ui key <key>` themselves; never ask for the key in chat or write it to a file.
- Docs: https://candor.themethodic.com/docs/webhooks

## Install

```bash
npx candor-ui add webhooks
```

Run `npx candor-ui@latest init` once first (https://candor.themethodic.com/get-started.md). Without the CLI: `npx shadcn@latest add @candor/webhooks`.
It brings in: alert-dialog, button, checkbox, data, input, locale, locked, page-state, relative-time, switch, tokens, utils.
npm packages: lucide-react@^1.67.0.

## Example

```tsx
"use client"

import * as React from "react"
import { Webhooks, type Webhook } from "@/components/webhooks"

const ago = (m: number) => new Date(Date.now() - m * 60_000).toISOString()
const events = [
  { id: "run.finished", label: "Run finished" },
  { id: "run.failed", label: "Run failed" },
  { id: "approval.requested", label: "Approval requested", description: "An agent is waiting on a person" },
]

export default function Example() {
  const [hooks, setHooks] = React.useState<Webhook[]>([
    {
      id: "w1",
      url: "https://ops.example.com/hooks/agents",
      events: ["run.failed", "approval.requested"],
      enabled: true,
      deliveries: [
        { id: "d2", event: "run.failed", at: ago(12), outcome: "delivered", status: 200, durationMs: 142 },
        { id: "d1", event: "approval.requested", at: ago(95), outcome: "failed", status: 503, durationMs: 10_000, error: "Timed out after 10 s" },
      ],
    },
  ])
  return (
    <Webhooks
      className="max-w-xl"
      webhooks={hooks}
      events={events}
      onAdd={(url, chosen) => setHooks((h) => [...h, { id: url, url, events: chosen, enabled: true, deliveries: [] }])}
      onToggle={(id, enabled) => setHooks((h) => h.map((x) => (x.id === id ? { ...x, enabled } : x)))}
      onTest={async () => {
        await new Promise((r) => setTimeout(r, 500))
        return { id: "test", event: "run.finished", at: new Date().toISOString(), outcome: "delivered", status: 200, durationMs: 98 }
      }}
      onRemove={(id) => setHooks((h) => h.filter((x) => x.id !== id))}
    />
  )
}
```

## <Webhooks>

Props:

- `webhooks: Webhook[]`: The endpoints.
- `events: WebhookEvent[]`: Every event an endpoint can subscribe to.
- `onAdd?: ((url: string, events: string[]) => void | Promise<void>) | undefined`: Shows Add endpoint. Called with the URL and the event ids ticked; the form waits on a returned promise.
- `onToggle?: ((id: string, enabled: boolean) => void) | undefined`: Shows a switch on each endpoint; called with its id and whether it should now be on.
- `onTest?: ((id: string) => Promise<WebhookDelivery>) | undefined`: Shows Send test on each endpoint. Resolve with the delivery the app recorded, and its result shows under the endpoint. If it throws or rejects, the endpoint says the test couldn't be sent, with the error's message as the reason.
- `onRemove?: ((id: string) => void | Promise<void>) | undefined`: Shows Remove on each endpoint. Called with its id once the person confirms; the dialog waits on a returned promise. If it throws or rejects, the dialog stays open and says it failed.
- `error?: string | undefined`: Why the last add failed, in the app's words, shown in the form.
- `state?: ListState | undefined`: What the list is showing: its rows (ready, the default), loading, empty or error with the reason, or partial with a note over the rows. Loading, empty and error draw in place of the rows, in this component's own words, so a list the app doesn't have yet isn't shown as an empty one.
- `onRetry?: (() => void) | undefined`: Shows Try again when `state` is an error; called when it's pressed. Pass it only when it loads the list again.
- `className?: string | undefined`

## Strings (`webhooks`)

The fixed words, English by default. Override any of them under `webhooks` in `<LocaleProvider strings={{ webhooks: { … } }}>`; the table is exported as `webhooksStrings`.

- `label`: Webhooks
- `heading`: Webhooks
- `add`: Add endpoint
- `empty`: No endpoints yet. Nothing is sent anywhere.
- `loading`: Loading endpoints
- `error`: Endpoints couldn’t be loaded.
- `url`: Endpoint URL
- `urlPlaceholder`: https://example.com/hooks/agents
- `events`: Events to send
- `addIt`: Add
- `adding`: Adding…
- `cancel`: Cancel
- `needsUrl`: Enter the endpoint’s address.
- `needsHttps`: Use an https:// address, so events are encrypted on the way.
- `needsEvent`: Choose at least one event.
- `sending`: Sends {events}
- `paused`: Paused: nothing is sent to it
- `sendTo`: Send events to {url}
- `test`: Send test
- `testing`: Sending…
- `testDelivered`: Test delivered
- `testFailed`: Test failed
- `testPending`: Test sent, no answer yet
- `testNotRun`: Couldn’t send the test
- `testNotRunWhy`: No delivery was recorded, so whether the endpoint answers isn’t known.
- `deliveries`: Recent deliveries
- `noDeliveries`: Nothing delivered yet.
- `event`: Event
- `when`: When
- `result`: Result
- `delivered`: Delivered
- `failed`: Failed
- `pending`: Pending
- `httpStatus`: HTTP {code}
- `took`: {ms} ms
- `remove`: Remove
- `removeEndpoint`: Remove {url}
- `removeTitle`: Remove this endpoint?
- `removeStops`: {url} stops receiving events from the next one on. Deliveries already made aren’t recalled.
- `removeIrreversible`: Removing can’t be undone. To send to it again, add it again.
- `keep`: Keep it
- `addFailed`: Adding the endpoint failed.
- `removeFailed`: Removing failed. Try again, or keep the endpoint.
- `keepLocked`: Removing is under way, so the endpoint can’t be kept now. If it fails, you can keep it.

## Types

### WebhookDelivery

One attempt to deliver an event to an endpoint, as the app recorded it.

```ts
{
  id: string;
  /** The event's id from `events`. */
  event: string;
  at: string;
  outcome: "delivered" | "failed" | "pending";
  /** The HTTP status the endpoint answered with, when it answered. */
  status?: number | undefined;
  /** How long the endpoint took to answer, in ms, as measured by the app. */
  durationMs?: number | undefined;
  /** Why it failed, in the app's words ("Timed out after 10 s"). */
  error?: string | undefined;
}
```

### Webhook

An endpoint the app sends events to.

```ts
{
  id: string;
  url: string;
  /**
   * How the endpoint reads on screen, in its labels and in the remove dialog, in place of `url`: "Slack #deploys",
   * "hooks.slack.com/…/B07". Set it when the URL carries a secret, as Slack and Discord webhook URLs do, so the token
   * isn't shown or read aloud. Left out, the URL is.
   */
  label?: string | undefined;
  /** Event ids from `events`. */
  events: string[];
  /** Whether events are sent to it now. */
  enabled: boolean;
  /** The latest deliveries, newest first. Leave it out when the app doesn't keep them. */
  deliveries?: WebhookDelivery[] | undefined;
}
```

### WebhookEvent

An event an endpoint can subscribe to.

```ts
{
  id: string;
  /** What happened, e.g. "Run finished". */
  label: string;
  description?: string | undefined;
}
```

## Types from other items

The props above take these. Each is defined by another item; import it from the path shown.

### ListState

Import: `import type { ListState } from "@/lib/data"` (item `data`, https://candor.themethodic.com/docs/data.md)

What a list or table is showing, the same shape as `ChartState`: its rows (ready), the shape of rows still coming (loading), nothing and why (empty), the rows it has with a note on what's missing (partial), or why it couldn't load (error). Lists without a `state` show their rows.

```ts
ChartState
```

### ChartState

Import: `import type { ChartState } from "@/lib/data"` (item `data`, https://candor.themethodic.com/docs/data.md)

```ts
{
  kind: "ready";
} | {
  kind: "loading";
}
/** Nothing to plot, and why. */
 | {
  kind: "empty";
  reason: string;
}
/** Some of the range is missing. */
 | {
  kind: "partial";
  note: string;
} | {
  kind: "error";
  reason: string;
}
```

## Rules for using Candor UI

- Components take plain typed data. Never pass model output as HTML or render it as a link; messages are typed blocks.
- A control that can't be used says why: wrap it in `<Locked reason="…">`, don't just disable it.
- Don't show a value you don't have: leave it out (it renders as —) rather than guess. An estimate is labelled as one.
- Nothing is pre-selected in a decision a person makes, and the agent is named from your app's data, never from model output.
- Your app's words go in `LocaleProvider` (fixed strings) and `TermsProvider` (nouns); entity kinds and agent roles in `EntityKindsProvider` and `AgentRolesProvider`.
