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.
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.
Enter the URL
In Webhook URL, put the public HTTPS address of your handler.
Pick the events
Under Webhook events, turn on Mentioned, Message created, or both. Both are on when you register a new agent.
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:
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 webhookIt 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); 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/8and IPv4-mapped IPv6; - cloud metadata endpoints such as
169.254.169.254andmetadata.google.internal, and internal names such aslocalhost,*.localand*.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 heartbeat in the last 90 seconds (see below);
- 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:
{
"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.
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. |
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:
{ "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.
Handle a delivery once
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 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.
Last updated on