> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sente.run/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Push notifications for inbound mail and run state changes, verified with a per-org secret header.

Sente pushes events to an HTTPS endpoint you register: inbound mail (`message.received`) and run state
changes (`run.blocked`, `run.completed`, `run.failed`). Every delivery carries your organization's
webhook secret in the `X-Sente-Secret` header.

## Register an endpoint

`POST /v1/webhooks` with `{ url, events, identityId? }`. Omit `identityId` for an org-wide webhook;
set it to scope deliveries to one identity. Registration is idempotent per (url, identity scope) —
registering the same pair again does not create a duplicate.

<CodeGroup>
  ```bash CLI theme={null}
  sente webhook register --url https://your-host.example.com/sente \
    --identity <id> --events message.received,run.blocked
  # → prints a "Secret:" line — store it, e.g.:
  # echo "SENTE_WEBHOOK_SECRET=<the Secret: value>" >> .env

  sente webhook list
  sente webhook delete <webhookId>
  ```

  ```ts TypeScript theme={null}
  const { secret } = await sente.webhooks.register({
    url: "https://your-host.example.com/sente",
    events: ["message.received", "run.blocked"],
    identityId: idt.id, // omit for org-wide
  });
  // store `secret` — webhooks.list() never returns it

  await sente.webhooks.list();
  await sente.webhooks.delete(webhookId);
  ```

  ```python Python theme={null}
  hook = sente.webhooks.register(
      url="https://your-host.example.com/sente",
      events=["message.received", "run.blocked"],
      identity_id=idt.id,  # omit for org-wide
  )
  # store hook.secret — webhooks.list() never returns it

  sente.webhooks.list()
  sente.webhooks.delete(webhook_id)
  ```
</CodeGroup>

<Warning>
  The secret is returned at registration only — `GET /v1/webhooks` never includes it. It is
  **org-level**: one secret verifies every delivery to your organization, across all webhooks and
  identities. Store it like an API key.
</Warning>

**URL requirements:** the URL must be http(s) and resolve to a public address. Loopback, private-range
(RFC 1918), link-local, and cloud-metadata addresses are rejected at registration (`400`) and
re-checked before every delivery.

## Verifying deliveries

Compare the `X-Sente-Secret` header against your stored secret with a timing-safe compare, then ack
fast:

```ts theme={null}
import { timingSafeEqual } from "node:crypto";

app.post("/sente", express.json(), async (req, res) => {
  const got = Buffer.from(req.header("x-sente-secret") ?? "");
  const want = Buffer.from(process.env.SENTE_WEBHOOK_SECRET!);
  if (got.length !== want.length || !timingSafeEqual(got, want)) return res.sendStatus(401);
  res.sendStatus(200); // ack fast, process after
  // ...handle req.body
});
```

## Events

| Event              | When                                          | Delivery                                                             |
| ------------------ | --------------------------------------------- | -------------------------------------------------------------------- |
| `message.received` | An inbound email lands in an identity's inbox | Queued; retried with backoff. At-least-once — dedup by `message.id`. |
| `run.blocked`      | A run parks needing a human                   | Single attempt, fired the instant the run blocks.                    |
| `run.completed`    | A run finishes successfully                   | Single attempt.                                                      |
| `run.failed`       | A run fails                                   | Single attempt.                                                      |

The `run.*` events are single-attempt: if your endpoint is down at that moment, the notification is
lost. `GET /v1/runs/:id` is the backstop — poll it if you must not miss a terminal state. See the
[runs API reference](/api-reference/runs).

### `message.received`

A **thin notification** — it has no body. Fetch the full message (body and
[annotation](/guides/receive-verification-codes)) with `GET /v1/messages/:id` /
`sente.messages.get(id)`:

```json theme={null}
{
  "type": "message.received",
  "message": {
    "id": "msg_...",
    "channel": "email",
    "identity": { "id": "idt_...", "name": "support-bot", "email": "support-bot@sente.run" },
    "from": "user@example.com",
    "to": "support-bot@sente.run",
    "subject": "hi",
    "createdAt": "2026-07-01T12:00:00.000Z"
  }
}
```

### `run.blocked`

Fired when a register, login, or connect run needs a human — a CAPTCHA, an SMS/phone step, a payment
step, a [confirm-before-submit](/guides/register) pause, or an MFA code only the account owner can
receive. The payload carries everything needed to route a person to the gate:

```json theme={null}
{
  "type": "run.blocked",
  "run": {
    "id": "run_...",
    "type": "register",
    "status": "blocked",
    "code": "SUBMIT_CONFIRMATION_REQUIRED",
    "detail": "...",
    "confirmBeforeSubmit": true,
    "registrationId": "reg_...",
    "appUrl": "https://app.example.com",
    "identity": { "id": "idt_...", "name": "...", "email": "...@sente.run" },
    "liveViewUrl": "https://...",
    "actionUrl": "https://app.sente.run/runs/run_...",
    "holdExpiresAt": "2026-07-01T12:10:00.000Z",
    "createdAt": "2026-07-01T12:00:00.000Z"
  }
}
```

* `code` — why the run blocked: `SUBMIT_CONFIRMATION_REQUIRED`, `CAPTCHA_REQUIRED`,
  `MISSING_FIELD`, `PAYMENT_REQUIRED`, `PHONE_REQUIRED`, `LOGIN_UNCONFIRMED`, or (connect runs)
  `TOTP_REQUIRED` / `MFA_REQUIRED`.
* `liveViewUrl` — the interactive browser view where a person clears the step.
* `actionUrl` — a [dashboard](https://app.sente.run) deep link to the run page (live view, submit,
  and resume in one place).
* `holdExpiresAt` — the run holds for roughly 10 minutes, then auto-fails with `BLOCKED_TIMEOUT` if
  not resumed. A notification should tell the human how long they have.

Not fired for a user-initiated pause (manual intervention from the dashboard) — the user already
knows. See [Runs and human takeover](/concepts/runs-and-human-takeover) for the block/resume flow.

### `run.completed` / `run.failed`

```json theme={null}
{
  "type": "run.completed",
  "run": {
    "id": "run_...",
    "type": "register",
    "status": "completed",
    "code": null,
    "detail": null,
    "registrationId": "reg_...",
    "appUrl": "https://app.example.com",
    "identity": { "id": "idt_...", "name": "...", "email": "...@sente.run" },
    "result": { "...": "..." },
    "createdAt": "2026-07-01T12:00:00.000Z",
    "endedAt": "2026-07-01T12:03:00.000Z"
  }
}
```

`run.failed` is the same shape with `status: "failed"`, the error `code`/`detail` set, and
`result: null`.

## Desktop notifications without a webhook

`sente watch` is the pull-based counterpart for a human at a machine — a daemon that polls and fires
an OS desktop notification the moment any run blocks, with the live-view URL and the
`sente run resume <id>` command:

```bash theme={null}
sente watch                    # all identities
sente watch --identity <ref> --interval 5
```

## Testing locally

No public URL needed during development — the CLI relays inbound mail to a local handler with the
exact same payload and `X-Sente-Secret` header:

```bash theme={null}
SENTE_WEBHOOK_SECRET=<your-secret> sente listen --identity <id> --forward http://localhost:3000/sente
```
