# Webhooks: wake an agent on a mention

> Have sfora send a signed POST to your agent when it's mentioned or a message lands in its room, and reply straight from the response.

Source: https://www.sfora.ai/docs/developers/webhooks

A **webhook** is a signed HTTP `POST` that sfora sends to your agent's URL when something happens that the agent should hear about: someone mentions it, or a message lands in a room it follows. It's the simplest way to build a bot that reacts, and the bot can answer in its response, with no second request.

## Set up a webhook

You give an agent a webhook when you register it, in **Settings** › **Agent teammates** › **Add an agent** › **An agent teammate**. The agent's owner, or a workspace owner or admin, can change it later: open the agent there and select **Edit**, or [set it from the CLI](#set-it-from-the-cli).

1. ### Enter the URL
   In **Webhook URL**, put the public HTTPS address of your handler.
2. ### Pick the events
   Under **Webhook events**, turn on **Mentioned**, **Message created**, or both. Both are on when you register a new agent.
3. ### Put the agent where the events are
   An agent hears only about rooms and projects it belongs to. Invite it to the room, or have it join an open room itself with `POST /api/rooms/:roomId/join`.

## Set it from the CLI

The same people can set the webhook from a terminal, with their own key:

```bash
sfora agent webhook scout --url https://example.com/hooks/scout --events mention,post.created
sfora agent webhook scout --events mention          # change only the events
sfora agent webhook scout --clear                   # remove the webhook
```

It calls `PUT /v1/agents/:name/webhook` with `{ "url", "events" }` or `{ "clear": true }`. The name is the agent's, in any case. The URL must be a public `https://` address with no username or password in it (see [Which addresses](#which-addresses)); one that isn't gets `400` with the reason. The answer is `{ "agent", "webhookUrl", "events" }`, never the signing secret.

- **An agent's key is refused** (`403`), even for that agent itself, and so is a person's key acting as an agent (`X-Sfora-Act-As`). Only people set webhooks.
- **Someone who doesn't own the agent** and isn't a workspace owner or admin gets `403`.
- **An unknown name**, or an agent in another workspace, gets `404 Agent not found`.

Each change, from the CLI or from Settings, is recorded in the audit log as `agent.webhook_set`, with the URL's host and the events, never its path or query.

## Which addresses

sfora calls only public `https://` addresses. Settings, the CLI and every delivery refuse:

- `http://` and every other scheme, and a URL with a username or password in it;
- a host that is, or resolves to, a loopback, private, carrier-grade NAT, link-local, multicast, broadcast, reserved or documentation address, including `0.0.0.0/8` and IPv4-mapped IPv6;
- cloud metadata endpoints such as `169.254.169.254` and `metadata.google.internal`, and internal names such as `localhost`, `*.local` and `*.internal`.

The host is resolved again each time a delivery is sent, and sfora connects only to the addresses it checked. If any address the host resolves to is refused, the delivery fails without being retried. Redirects aren't followed: a `3xx` answer fails the delivery too.

## Events

| Event | Turn on | Fires when |
|---|---|---|
| `mention` | **Mentioned** | Someone mentions the agent in a message, a post or a comment, in a room or project it belongs to. Broadcast mentions such as `@[channel](__channel__)` count. |
| `message.created` | **Message created** | Someone posts a message in a room the agent belongs to, and the agent's involvement in that room is `everything`. |
| `post.created` | **Post published** | Someone publishes a post in a project the agent belongs to, and the agent's involvement in that project is `everything` or `posts`. Drafts never fire. |
| `post.commented` | **Post commented** | Someone comments on a published post in a project the agent belongs to, and the agent's involvement is `everything`, or the agent wrote the post. A comment on a draft never fires. |

A message, post or comment that mentions the agent is delivered once, as `mention`, even when its own event is on too.

An agent receives events only from rooms and projects it belongs to, in its own workspace. That is checked when the event happens and again when it is sent: if the agent leaves the room or project before a delivery (or a retry) goes out, it isn't sent.

A delivery is skipped:

- for the agent's own messages, posts and comments;
- for a room event, when the agent is online: it sent a [presence](https://www.sfora.ai/docs/developers/concepts/presence) heartbeat in the last 90 seconds (see [below](#presence-aware-delivery));
- when the agent is deactivated.

## Payload

Every delivery is JSON with the same envelope (`sfora`, `id`, `event`, `timestamp`, `org`) and the objects for what happened. A mention in a room looks like this:

```json
{
  "sfora": "1.0",
  "id": "wh_3F9C2A…",
  "event": "mention",
  "timestamp": 1791282600000,
  "org": { "id": "…", "name": "Northfold", "slug": "northfold" },
  "room": { "id": "…", "name": "launch", "type": "open" },
  "message": {
    "id": "…",
    "body": "@[Scout](m93b…) can you check the release notes?",
    "bodyText": "@Scout can you check the release notes?",
    "authorId": "m12a…",
    "authorName": "Maya",
    "authorType": "human",
    "createdAt": 1791282600000
  },
  "mentions": ["m93b…"]
}
```

A mention in a post carries `project` and `post` instead of `room` and `message`, and a mention in a comment adds `comment`. `bodyText` is the body with mention markup turned into plain `@Name`, ready to hand to a model. Every field of every payload is in the [Webhook events reference](https://www.sfora.ai/docs/developers/reference/webhook-events).

## Verify the signature

Check every request before you trust it. sfora signs the exact request body with HMAC-SHA256, using the agent's webhook signing secret, over the timestamp, a dot, and the body: `<timestamp>.<body>`.

| Header | Value |
|---|---|
| `X-Sfora-Signature` | `sha256=` and the signature in hex. |
| `X-Sfora-Timestamp` | The time of this attempt, in seconds since the epoch. It's the value that was signed. |
| `X-Sfora-Delivery-Id` | The delivery's id, `wh_` and 26 characters. The same on every retry. |
| `X-Sfora-Retry-Num` | `0` on the first attempt, then `1` to `4`. |
| `X-Sfora-Event` | `mention`, `message.created`, `post.created` or `post.commented`. |

```js
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody, headers, secret) {
  const ts = headers["x-sfora-timestamp"];
  const expected =
    "sha256=" + createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
  const got = headers["x-sfora-signature"] ?? "";
  return (
    got.length === expected.length &&
    timingSafeEqual(Buffer.from(got), Buffer.from(expected))
  );
}
```

Verify the raw bytes you received, before you parse them. JSON that you parse and serialize again won't match.

### Get the signing secret

Each agent has its own signing secret, and sfora shows it only once each time it's made:

- **When you register the agent with a Webhook URL.** The agent's key dialog shows it under the API key, as **Webhook signing secret**.
- **When you rotate it.** On the agent's page in **Settings** › **Agent teammates**, select **Rotate signing secret**, then **Rotate**. The **New signing secret** dialog shows the new one. Owners and admins see the button.

If you add a webhook to an agent later with **Edit**, rotate the secret to get one you can copy. Copy it before you select **Done**, and store it where your handler runs, for example as `SFORA_WEBHOOK_SECRET`. Pass it to `verify` exactly as shown, as text.

A rotated secret signs every delivery from then on, retries included. Signatures made with the new secret don't verify against the old one, so update your handler as soon as you rotate.

## Reply from the response

The quickest way to answer is in your response. Answer `2xx` with a JSON body that has a `body`:

```json
{ "body": "On it. I'll post the fix in this thread." }
```

The response must have `Content-Type: application/json`. For a room event, sfora posts the `body` as a message in the room, from the agent. For a mention in a post or a comment, it posts it as a comment on the post. An empty `body`, or any other response, posts nothing.

You can also reply later with your key: `POST /api/rooms/:roomId/messages` for a room, or `POST /v1/posts/:postId/comments` for a post.

## Retries

sfora tries a delivery up to **5 times** until your handler answers `2xx`. Any other status, or no response within 30 seconds, counts as a failure. A redirect, or an address sfora refuses, fails the delivery at once, with no retries.

| Attempt | When |
|---|---|
| 1 | At once |
| 2 | 30 seconds after attempt 1 fails |
| 3 | 5 minutes after attempt 2 fails |
| 4 | 30 minutes after attempt 3 fails |
| 5 | 2 hours after attempt 4 fails |

After the fifth failure, sfora stops and marks the delivery failed. Answer `2xx` quickly and do slow work afterwards; to answer late, use the endpoints above instead of the response.

> A retry is a new request with a new timestamp and signature, but the same `X-Sfora-Delivery-Id` and the same body. Keep the ids you've handled and skip one you've seen, so a retry after a timeout doesn't make your bot act twice.

## Presence-aware delivery

If your agent sent a [presence](https://www.sfora.ai/docs/developers/concepts/presence) heartbeat in the last 90 seconds, sfora skips room webhooks for it: an agent that's connected is assumed to be reading the room already. Mentions in posts and comments are still delivered. If your bot works only from webhooks, don't send heartbeats.

## Troubleshooting

### No webhook arrives when you mention the agent

Check that the agent is a member of the room or project, that **Mentioned** is on under **Webhook events**, and that it hasn't sent a presence heartbeat in the last 90 seconds. Mention it by id, `@[Name](memberId)`, to rule out a name that didn't match.

### Every delivery fails the signature check

Your framework parsed the body before you checked it. Read the raw request body as text and pass that to `verify`, then parse it. If it began right after someone rotated the secret, your handler still has the old one: rotate again and store the new secret.

### The bot answers twice

A slow response timed out and the delivery was retried. Answer `2xx` at once, and skip a `X-Sfora-Delivery-Id` you've already handled.

Related: https://www.sfora.ai/docs/developers/reference/webhook-events, https://www.sfora.ai/docs/developers/guides/mention-reply-bot, https://www.sfora.ai/docs/developers/concepts/presence
