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

# Get notified with webhooks

> Receive signed HTTP notifications when your sessions change status.

export const Webhooks = () => {
  const stroke = {
    fill: "none",
    stroke: "currentColor",
    strokeWidth: 1.75,
    strokeLinecap: "round",
    strokeLinejoin: "round"
  };
  const S = c => ({
    className: c,
    ...stroke
  });
  const icons = {
    session: c => <svg viewBox="0 0 24 24" {...S(c)}><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>,
    platform: c => <svg viewBox="0 0 24 24" {...S(c)}><rect width="20" height="8" x="2" y="2" rx="2" /><rect width="20" height="8" x="2" y="14" rx="2" /><path d="M6 6h.01M6 18h.01" /></svg>,
    endpoint: c => <svg viewBox="0 0 24 24" {...S(c)}><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10" /><path d="m9 12 2 2 4-4" /></svg>
  };
  const Card = ({icon, title, sub, children}) => <div className="flex shrink-0 flex-col self-center rounded-xl border border-zinc-200 bg-white p-5 dark:border-zinc-800 dark:bg-zinc-950">
      <div className="flex items-center gap-2.5">
        <span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200">{icon("h-5 w-5")}</span>
        <div>
          <div className="whitespace-nowrap text-base font-semibold leading-6 text-zinc-900 dark:text-zinc-100">{title}</div>
          {sub && <div className="whitespace-nowrap text-sm text-zinc-500 dark:text-zinc-400">{sub}</div>}
        </div>
      </div>
      {children}
    </div>;
  const Arrow = ({label}) => <div className="flex min-w-[72px] flex-1 flex-col items-stretch justify-center gap-1 px-3 text-center text-xs leading-4 text-zinc-500 dark:text-zinc-400">
      <span className="whitespace-nowrap">{label || "\u00a0"}</span>
      <div className="flex items-center text-zinc-400 dark:text-zinc-600">
        <span className="h-px flex-1 bg-current" />
        <svg className="-ml-px h-3 w-2 shrink-0" viewBox="0 0 8 12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M1 1.5 6 6l-5 4.5" /></svg>
      </div>
    </div>;
  const Chip = ({children}) => <span className="whitespace-nowrap rounded-md bg-zinc-100 px-2 py-0.5 font-mono text-xs text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">{children}</span>;
  return <div className="not-prose my-8 overflow-x-auto">
      <div className="flex min-w-[680px] items-stretch">
        <Card icon={icons.session} title="Session" sub="status changes">
          <div className="mt-4 flex flex-col items-start gap-1.5">
            <Chip>running</Chip>
            <svg className="ml-2 h-3.5 w-3.5 text-zinc-400 dark:text-zinc-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14" /><path d="m19 12-7 7-7-7" /></svg>
            <Chip>completed</Chip>
          </div>
        </Card>

        <Arrow label="event" />

        <Card icon={icons.platform} title="H Platform" sub="signs and sends">
          <pre className="mt-4 rounded-lg bg-zinc-100 p-3 font-mono text-[11px] leading-[1.6] text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">{`POST /hooks
X-H-Webhook-Signature: ...
X-H-Webhook-Timestamp: ...

{ "type": "session.status_updated",
  "data": { "status": "completed" } }`}</pre>
        </Card>

        <Arrow label="HTTPS" />

        <Card icon={icons.endpoint} title="Your endpoint" sub="verifies, then acts">
          <div className="mt-4 flex flex-col gap-2 text-sm text-zinc-600 dark:text-zinc-400">
            <div>1. Check the signature</div>
            <div>2. <span className="font-mono text-[13px]">GET /status</span> for the truth</div>
          </div>
        </Card>
      </div>
    </div>;
};

export const Notice = ({kind = "note", title, children}) => {
  const kinds = {
    warning: {
      label: "User notice",
      icon: <>
          <path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
          <path d="M12 9v4" />
          <path d="M12 17h.01" />
        </>
    },
    gotcha: {
      label: "Gotcha",
      icon: <>
          <circle cx="12" cy="12" r="10" />
          <path d="M12 16v-4" />
          <path d="M12 8h.01" />
        </>
    },
    note: {
      label: "Note",
      icon: <>
          <circle cx="12" cy="12" r="10" />
          <path d="M12 16v-4" />
          <path d="M12 8h.01" />
        </>
    }
  };
  const k = kinds[kind];
  return <div className="notice my-6 rounded-xl border border-zinc-200 bg-white p-5 dark:border-zinc-800 dark:bg-zinc-950">
      <div className={`${kind === "warning" ? "not-prose flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-red-400/80 dark:text-red-400/70" : kind === "gotcha" ? "not-prose flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-amber-500/80 dark:text-amber-400/70" : "not-prose flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-zinc-400 dark:text-zinc-500"}`}>
        <svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
          {k.icon}
        </svg>
        {k.label}
      </div>
      {title && <div className="not-prose mt-2 text-base font-semibold text-zinc-900 dark:text-zinc-100">{title}</div>}
      <div className="notice-body mt-3 text-sm leading-6 text-zinc-700 dark:text-zinc-300">{children}</div>
    </div>;
};

<Webhooks />

A webhook is an HTTPS URL you register for your organization. When a [session](/agents-api/sessions/overview) changes status, the platform sends a signed `POST` request to every subscribed webhook, so you can react to completions and failures without polling.

Manage webhooks with the [CRUD API](/agents-api/webhooks/create). Each one has a target `url`, a list of `enabled_events`, and a signing `secret` returned once at creation.

## Events

| Event type                      | Sent when                                                                      |
| ------------------------------- | ------------------------------------------------------------------------------ |
| `session.status_updated`        | A session's status changes, e.g. `running` → `completed`.                      |
| `session.completed`             | A session finishes successfully.                                               |
| `session.failed`                | A session fails.                                                               |
| `session.timed_out`             | A session exceeds its time limit.                                              |
| `session.idle`                  | The agent answered and is waiting for the next message.                        |
| `session.awaiting_tool_results` | The agent is waiting for [client-side tool results](/agents-api/custom-tools). |

All event types share the same payload shape. `"*"` subscribes to the `session.status_updated` firehose. Each type in `enabled_events` is delivered independently: subscribing to both `session.status_updated` and `session.failed` gets you two deliveries when a session fails, one per type. List the available types programmatically with [List event types](/agents-api/webhooks/events).

## Delivery payload

Each delivery is a `POST` with a JSON body:

```json Delivery payload theme={"system"}
{
  "type": "session.status_updated",
  "id": "evt_5d1f0c9e8a7b4c2da93f1e6b8c4d2a70",
  "created_at": "2026-06-11T15:04:05.123Z",
  "data": {
    "session_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
    "status": "completed",
    "previous_status": "running"
  }
}
```

<ResponseField name="type" type="string">
  The event type, e.g. `session.status_updated`.
</ResponseField>

<ResponseField name="id" type="string">
  Unique id for this event.
</ResponseField>

<ResponseField name="created_at" type="string">
  When the status change occurred (UTC, RFC 3339, millisecond precision).
</ResponseField>

<ResponseField name="data" type="object">
  Event payload: `session_id`, the new `status` (`queued`, `pending`, `running`, `paused`, `idle`, `awaiting_tool_results`, `completed`, `failed`, `timed_out`, or `interrupted`), and the `previous_status` it transitioned from (`null` when unknown).
</ResponseField>

## Verifying deliveries

Every delivery carries these headers:

| Header                  | Value                                                                                        |
| ----------------------- | -------------------------------------------------------------------------------------------- |
| `X-H-Webhook-Timestamp` | Unix timestamp (seconds) of the delivery attempt.                                            |
| `X-H-Webhook-Signature` | `sha256=` + hex HMAC-SHA256 of `{timestamp}.{raw_body}`, keyed with your webhook's `secret`. |
| `X-H-Webhook-Delivery`  | Unique id for this delivery, stable across retries.                                          |

Always verify before trusting a delivery. The SDKs ship a helper that checks the signature, rejects stale deliveries (older than 5 minutes by default), and parses the event.

<CodeGroup>
  ```python Python (FastAPI) theme={"system"}
  from fastapi import FastAPI, Header, HTTPException, Request
  from hai_agents import WebhookEventData, WebhookVerificationError, verify_webhook

  app = FastAPI()

  @app.post("/hooks/h")
  async def receive(
      request: Request,
      x_h_webhook_signature: str = Header(""),
      x_h_webhook_timestamp: str = Header(""),
  ):
      body = await request.body()
      try:
          event = verify_webhook(body, x_h_webhook_signature, x_h_webhook_timestamp, secret="whsec_...")
      except WebhookVerificationError:
          raise HTTPException(status_code=400, detail="invalid signature")
      # event.data is the raw payload; its shape depends on event.type.
      if event.type == "session.status_updated":
          data = WebhookEventData.model_validate(event.data)
          if data.status == "completed":
              print(f"session {data.session_id} finished")
      return {"ok": True}
  ```

  ```typescript TypeScript (Express) theme={"system"}
  import express from "express";
  import { type WebhookEventData, WebhookVerificationError, verifyWebhook } from "hai-agents";

  const app = express();
  app.use("/hooks/h", express.raw({ type: "application/json" }));

  app.post("/hooks/h", (req, res) => {
    try {
      const event = verifyWebhook(
        req.body,
        req.header("X-H-Webhook-Signature") ?? "",
        req.header("X-H-Webhook-Timestamp") ?? "",
        "whsec_...",
      );
      // event.data is the raw payload; its shape depends on event.type.
      if (event.type === "session.status_updated") {
        const data = event.data as unknown as WebhookEventData;
        if (data.status === "completed") {
          console.log(`session ${data.session_id} finished`);
        }
      }
      res.json({ ok: true });
    } catch (e) {
      if (e instanceof WebhookVerificationError) {
        res.status(400).json({ error: "invalid signature" });
        return;
      }
      throw e;
    }
  });
  ```
</CodeGroup>

<Notice kind="gotcha" title="Verify the raw bytes">
  Sign-check the request body exactly as received. Parsing and re-serializing the JSON changes the bytes and invalidates the signature.
</Notice>

To verify manually: compute `HMAC-SHA256(secret, "{timestamp}." + raw_body)`, hex-encode it, prefix with `sha256=`, and compare it to `X-H-Webhook-Signature` using a constant-time comparison. Reject deliveries whose timestamp is more than a few minutes old to guard against replays.

## Delivery semantics

Deliveries are at-least-once, with a 10-second timeout per attempt. If your endpoint is unreachable or returns a non-2xx status, delivery is retried with increasing backoff, up to 8 attempts spanning about 24 hours, after which the event is dropped.

Because of retries:

* **Deduplicate.** The same event can arrive more than once. The event `id` and the `X-H-Webhook-Delivery` header are stable across retries. Skip ids you have already processed.
* **Ignore arrival order.** A retried old event can land after a newer one. Trust the event's own `status`, `created_at`, and `previous_status`, not the order of arrival.
* **Return 2xx quickly.** Any other status counts as a failure and schedules a retry. Do slow work after responding.

Webhooks are a trigger rather than a source of truth: on receipt, fetch the authoritative state with [Get session status](/agents-api/sessions/status).

Each webhook records the result of its latest delivery attempt: [Retrieve](/agents-api/webhooks/retrieve) returns `last_delivery_status`, `last_delivery_error`, `last_delivery_at`, `last_success_at`, and `consecutive_failures`, so you can check whether an endpoint is healthy, and why it was disabled, without digging through receiver logs.

## Testing an endpoint

[Ping](/agents-api/webhooks/ping) sends a signed `ping` event through the real delivery path and returns your endpoint's HTTP response synchronously, so you can validate URL, signature verification, and connectivity before relying on the webhook.

## Rotating the secret

[Rotate](/agents-api/webhooks/rotate) replaces the signing secret without a verification gap:

1. Deploy your receiver passing **both** the current and a placeholder for the new secret to the verify helper (it accepts a list).
2. Call the rotate endpoint and store the new secret. Update the receiver's secret list.
3. Once deliveries verify against the new secret, remove the old one.

## Constraints

* Target URLs must be `https://` and resolve to a public address. Reachability is verified with a `HEAD` request when you register or change the URL, and deliveries to private or internal hosts fail (and count as failed attempts).
* The signing `secret` is returned only by [Create](/agents-api/webhooks/create) and [Rotate](/agents-api/webhooks/rotate).
* An organization can register up to 10 webhooks.
* A `disabled` webhook stays registered but receives no deliveries.
* After 50 consecutive failed delivery attempts, a webhook is automatically disabled. Fix the receiver, then re-enable it with [Update](/agents-api/webhooks/update) (`{"disabled": false}`).

## Endpoints

| Method   | Path                                   | Description                                         |
| -------- | -------------------------------------- | --------------------------------------------------- |
| `POST`   | `/api/v2/webhooks`                     | [Create a webhook](/agents-api/webhooks/create)     |
| `GET`    | `/api/v2/webhooks`                     | [List webhooks](/agents-api/webhooks/list)          |
| `GET`    | `/api/v2/webhooks/{webhook_id}`        | [Retrieve a webhook](/agents-api/webhooks/retrieve) |
| `PATCH`  | `/api/v2/webhooks/{webhook_id}`        | [Update a webhook](/agents-api/webhooks/update)     |
| `DELETE` | `/api/v2/webhooks/{webhook_id}`        | [Delete a webhook](/agents-api/webhooks/delete)     |
| `GET`    | `/api/v2/webhooks/events`              | [List event types](/agents-api/webhooks/events)     |
| `POST`   | `/api/v2/webhooks/{webhook_id}/ping`   | [Ping a webhook](/agents-api/webhooks/ping)         |
| `POST`   | `/api/v2/webhooks/{webhook_id}/rotate` | [Rotate the secret](/agents-api/webhooks/rotate)    |

## Next steps

<CardGroup cols={2}>
  <Card title="Schedules" icon="clock" href="/agents-api/schedules/overview">
    Pair webhooks with recurring sessions nobody is polling.
  </Card>

  <Card title="Watch and steer sessions" icon="eye" href="/agents-api/observe-and-steer">
    Polling and streaming alternatives to webhooks.
  </Card>
</CardGroup>
