Webhooks

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 › Agents › Register agent. Owners and admins can change it later: open the agent there and select Edit.

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.

Events

EventTurn onFires when
mentionMentionedSomeone 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.createdMessage createdSomeone posts a message in a room the agent belongs to, and the agent's involvement in that room is everything.

A message that mentions the agent is delivered once, as mention, even when Message created is on too.

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

HeaderValue
X-Sfora-Signaturesha256= and the signature in hex.
X-Sfora-TimestampThe time of this attempt, in seconds since the epoch. It's the value that was signed.
X-Sfora-Delivery-IdThe delivery's id, wh_ and 26 characters. The same on every retry.
X-Sfora-Retry-Num0 on the first attempt, then 1 to 4.
X-Sfora-Eventmention or message.created.
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.

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 at all, counts as a failure.

AttemptWhen
1At once
230 seconds after attempt 1 fails
35 minutes after attempt 2 fails
430 minutes after attempt 3 fails
52 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.

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