> ## 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

> Register an endpoint, verify deliveries with your org secret, and handle every event Sente pushes — inbound mail and run state changes.

Webhooks are how a deployed agent reacts without polling. Sente `POST`s a JSON event to an HTTPS endpoint you own the moment something happens: an email lands in an identity's inbox (`message.received`), or a run needs a human, finishes, or fails (`run.blocked`, `run.completed`, `run.failed`).

Every delivery to your organization carries the same **org-level secret** in the `X-Sente-Secret` header. Verifying that header is the whole authentication story — there is no signature to compute and no raw-body handling, so a normal JSON body parser is fine.

## Register an endpoint

`POST /v1/webhooks` with `{ url, events, identityId? }`. Omit `identityId` for an org-wide subscription; set it to receive only that identity's events. Registration is idempotent per (url, identity scope): re-registering the same pair updates its event list instead of creating a duplicate, so it is safe to call on every app start.

<CodeGroup>
  ```bash CLI theme={null}
  sente webhook register \
    --url https://agent.example.com/sente/events \
    --events message.received,run.blocked,run.completed,run.failed
  # → prints a "Secret: whsec_…" line — store it:
  #   echo "SENTE_WEBHOOK_SECRET=whsec_…" >> .env

  sente webhook register --url https://agent.example.com/sente/events \
    --identity support-bot --events message.received   # scoped to one identity

  sente webhook list
  sente webhook delete whk_4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d
  ```

  ```ts TypeScript theme={null}
  import { Sente } from "@sente-labs/sdk";

  const sente = new Sente({ apiKey: process.env.SENTE_API_TOKEN! });

  const hook = await sente.webhooks.register({
    url: "https://agent.example.com/sente/events",
    events: ["message.received", "run.blocked", "run.completed", "run.failed"],
    // identityId: idt.id,   // omit for org-wide
  });
  console.log(hook.secret); // store it — list() never returns it

  await sente.webhooks.list();
  await sente.webhooks.delete(hook.id);
  ```

  ```python Python theme={null}
  import os
  from sente import Sente

  sente = Sente(api_key=os.environ["SENTE_API_TOKEN"])

  hook = sente.webhooks.register(
      url="https://agent.example.com/sente/events",
      events=["message.received", "run.blocked", "run.completed", "run.failed"],
      # identity_id=idt.id,   # omit for org-wide
  )
  print(hook.secret)  # store it — list() never returns it

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

<Warning>
  The secret is **org-level**: one value verifies every delivery to your organization, across all webhooks and all identities. It is returned when you register and never by `GET /v1/webhooks`. Store it like an API key — there is no self-serve rotation endpoint today.
</Warning>

**URL rules.** The URL must be `http(s)` and resolve to a public address. Loopback, private-range (RFC 1918), CGNAT, link-local, and cloud-metadata addresses are rejected at registration with `400` and re-checked immediately before every delivery, so a hostname that later resolves into private space stops being delivered to.

## Handle a delivery

Compare the header against your stored secret with a constant-time compare, acknowledge fast, then do the work. Sente gives each delivery 10 seconds before it counts as failed, so never block the response on your own processing.

<CodeGroup>
  ```ts TypeScript theme={null}
  import express from "express";
  import { timingSafeEqual } from "node:crypto";

  const app = express();

  app.post("/sente/events", express.json(), (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 first — handle after

    const event = req.body;
    switch (event.type) {
      case "message.received":
        // thin notification: fetch the body + annotation when you need them
        void handleMail(event.message.id);
        break;
      case "run.blocked":
        void pageAHuman(event.run); // code, liveViewUrl, actionUrl, holdExpiresAt
        break;
      case "run.completed":
      case "run.failed":
        void recordOutcome(event.run);
        break;
    }
  });

  app.listen(3000);
  ```

  ```python Python theme={null}
  import hmac
  import os
  from flask import Flask, request

  app = Flask(__name__)
  SECRET = os.environ["SENTE_WEBHOOK_SECRET"]

  @app.post("/sente/events")
  def sente_events():
      if not hmac.compare_digest(request.headers.get("X-Sente-Secret", ""), SECRET):
          return "", 401

      event = request.get_json(silent=True) or {}
      kind = event.get("type")
      if kind == "message.received":
          queue_mail(event["message"]["id"])       # thin notification — fetch the body later
      elif kind == "run.blocked":
          page_a_human(event["run"])               # code, liveViewUrl, actionUrl, holdExpiresAt
      elif kind in ("run.completed", "run.failed"):
          record_outcome(event["run"])
      return "", 200                               # ack fast; do the work off the request
  ```
</CodeGroup>

## Events and delivery semantics

| Event              | Fires when                                                       | Delivery                                                                       |
| ------------------ | ---------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| `message.received` | An inbound email has been stored and classified for an identity. | Queued, retried with backoff. **At-least-once** — deduplicate on `message.id`. |
| `run.blocked`      | A run parks needing a human.                                     | Single attempt, fired the instant the run blocks.                              |
| `run.completed`    | A run reaches a terminal success.                                | Single attempt.                                                                |
| `run.failed`       | A run reaches a terminal failure.                                | Single attempt.                                                                |

<Warning>
  The `run.*` events are **fire-and-forget**. If your endpoint is down, slow past 10 seconds, or returns a non-2xx at that moment, that notification is gone — there is no retry. For anything you must not miss, keep `GET /v1/runs/:id` (or `GET /v1/runs?status=blocked`) as a reconciliation path. `message.received` is the one queued event: a job whose deliveries all fail is retried, and eventually parked in a dead-letter queue.
</Warning>

Scoping is per subscription: an org-wide webhook (no `identityId`) receives events for every identity; an identity-scoped one receives only that identity's. If both match, both are delivered — deduplicate if you register overlapping subscriptions.

### `message.received`

A **thin notification** with no body. Fetch the full message — text, HTML, and the [annotation](/email/verification-codes) that carries an extracted OTP or magic link — with `GET /v1/messages/:id` (`sente.messages.get(id)`).

```json theme={null}
{
  "type": "message.received",
  "message": {
    "id": "msg_5b0e8d7c6f5a4e3d2c1b0a9f8e7d6c5b",
    "channel": "email",
    "identity": {
      "id": "idt_9f1c2ab34d5e46f7a8b9c0d1e2f3a4b5",
      "name": "support-bot",
      "email": "support-bot@sente.run"
    },
    "from": "no-reply@example-app.com",
    "to": "support-bot@sente.run",
    "subject": "Your verification code",
    "createdAt": "2026-07-20T09:15:02.114Z"
  }
}
```

<Note>
  Inbound only. An identity's own outbound sends never fire this event. And if you are waiting on a verification code *inside* a Sente-driven run, you do not need this at all — the run applies the code itself.
</Note>

### `run.blocked`

Fired the instant a register, login, or connect run parks for a human. It carries everything needed to route a person to the gate.

```json theme={null}
{
  "type": "run.blocked",
  "run": {
    "id": "run_8d9e0f1a2b3c4d5e6f708192a3b4c5d6",
    "type": "register",
    "status": "blocked",
    "code": "SUBMIT_CONFIRMATION_REQUIRED",
    "detail": "awaiting human submit",
    "confirmBeforeSubmit": true,
    "registrationId": "reg_1c2d3e4f5a6b708192a3b4c5d6e7f801",
    "appUrl": "https://app.example.com",
    "identity": {
      "id": "idt_9f1c2ab34d5e46f7a8b9c0d1e2f3a4b5",
      "name": "support-bot",
      "email": "support-bot@sente.run"
    },
    "liveViewUrl": "https://…",
    "actionUrl": "https://app.sente.run/runs/run_8d9e0f1a2b3c4d5e6f708192a3b4c5d6",
    "holdExpiresAt": "2026-07-20T11:10:00.000Z",
    "createdAt": "2026-07-20T11:00:00.000Z"
  }
}
```

| Field           | Use it for                                                                                                                                                                                                                                      |
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `code`          | Which gate stopped the run — `CAPTCHA_REQUIRED`, `PHONE_REQUIRED`, `PAYMENT_REQUIRED`, `MISSING_FIELD`, `SUBMIT_CONFIRMATION_REQUIRED`, `MFA_REQUIRED`, `TOTP_REQUIRED`, `LOGIN_UNCONFIRMED`. Full table: [Runs](/runs/overview#blocked-codes). |
| `liveViewUrl`   | The interactive browser view where a person clears the gate. Dies with the run — do not store it.                                                                                                                                               |
| `actionUrl`     | Dashboard deep link to the run page (live view, take over, and resume in one place). The stable link to put in an alert.                                                                                                                        |
| `holdExpiresAt` | When the run auto-fails with `BLOCKED_TIMEOUT`. Tell the human how long they have.                                                                                                                                                              |

Not fired when *you* pause a run yourself with `POST /v1/runs/:id/intervene` — you already know.

### `run.completed` and `run.failed`

```json theme={null}
{
  "type": "run.completed",
  "run": {
    "id": "run_8d9e0f1a2b3c4d5e6f708192a3b4c5d6",
    "type": "register",
    "status": "completed",
    "code": null,
    "detail": null,
    "registrationId": "reg_1c2d3e4f5a6b708192a3b4c5d6e7f801",
    "appUrl": "https://app.example.com",
    "identity": {
      "id": "idt_9f1c2ab34d5e46f7a8b9c0d1e2f3a4b5",
      "name": "support-bot",
      "email": "support-bot@sente.run"
    },
    "result": { "…": "…" },
    "createdAt": "2026-07-20T11:00:00.000Z",
    "endedAt": "2026-07-20T11:03:12.500Z"
  }
}
```

`run.failed` is the same shape with `"status": "failed"`, `result: null`, and the failure `code`/`detail` set — see the [failure codes](/runs/overview#failure-codes).

## Test locally without a public URL

The SSRF guard means `http://localhost:3000` can never be registered. Instead, have the CLI relay inbound mail to your local handler with the exact same envelope and `X-Sente-Secret` header:

```bash theme={null}
SENTE_WEBHOOK_SECRET=whsec_… \
  sente listen --identity support-bot --forward http://localhost:3000/sente/events
```

This covers `message.received` only. For `run.*`, drive a run and poll `GET /v1/runs/:id`, or expose a tunnel with a public hostname and register that.

## When deliveries don't arrive

| Symptom                                                     | Likely cause                                                                        | Fix                                                                                 |
| ----------------------------------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `400 {"error": "webhook url not allowed: private address"}` | The URL resolves to loopback, a private range, or link-local space.                 | Use a public hostname or a tunnel. Local development: `sente listen --forward`.     |
| `400` with zod issues on `events`                           | An event name outside the four supported values.                                    | Only `message.received`, `run.completed`, `run.failed`, `run.blocked` are accepted. |
| `404 {"error": "identity not found"}`                       | `identityId` belongs to another org or doesn't exist.                               | Check with `sente identity list`.                                                   |
| `401 {"error": "invalid api key"}`                          | Wrong or revoked API key on the register call.                                      | Mint a new key at [app.sente.run](https://app.sente.run).                           |
| Nothing arrives, no errors                                  | The subscription is identity-scoped and events are firing for a different identity. | `sente webhook list` — an org-wide subscription has `identityId: null`.             |
| Some `run.*` events missing                                 | Single-attempt delivery hit a redeploy, a cold start, or a >10s response.           | Reconcile with `GET /v1/runs`; keep the handler's ack immediate.                    |
| The same message twice                                      | `message.received` is at-least-once by design.                                      | Deduplicate on `message.id`.                                                        |

## Next steps

<CardGroup cols={2}>
  <Card title="Audit trail" icon="scroll-text" href="/events/audit-trail">
    The pull-based record of who touched credentials and sessions.
  </Card>

  <Card title="Human takeover" icon="hand" href="/runs/human-takeover">
    What to do with a `run.blocked` event once it reaches a person.
  </Card>

  <Card title="Verification codes" icon="mail-check" href="/email/verification-codes">
    The annotation on an inbound message, and waiting for one directly.
  </Card>

  <Card title="Webhooks API reference" icon="terminal" href="/api-reference/webhooks">
    Endpoint fields, status codes, and exact payloads.
  </Card>
</CardGroup>
