Appearance
Webhooks
Get a message in your own systems, or in a Slack or Discord channel, whenever something changes in your organization: a user is invited or disabled, joins a group, or an admin rotates a key.
Permission
Needs Manage API keys and webhooks.
Add an endpoint
Open Settings > Webhooks and choose Add endpoint.
Choose a format:
Format Sends URL Standard Signed JSON, following Standard Webhooks Any public httpsURLSlack A formatted message for a channel A Slack incoming webhook, https://hooks.slack.com/services/...Discord A formatted message for a channel A Discord webhook, https://discord.com/api/webhooks/...Enter the URL and an optional description.
Leave Send every event on, or choose the events you want.
For a Standard endpoint, copy the signing secret from the endpoint's page with Show secret. You'll need it to verify messages.
You can have up to 20 endpoints.
Slack and Discord
Each event becomes one message saying what happened, who it was about, who did it, and when, with a link to the right page in the console. These messages aren't signed. Text from the event is escaped, so it can't add links or mention anyone.
Verify messages
Check every Standard message is from Fob before you act on it. The Standard Webhooks libraries do this for you. To verify by hand:
- Compute an HMAC-SHA256 of
{webhook-id}.{webhook-timestamp}.{body}, keyed with the signing secret after removingwhsec_and base64-decoding it. Use the raw body, before parsing the JSON. - Compare it, in constant time, with each space-separated
v1,entry in thewebhook-signatureheader. - Reject messages whose
webhook-timestampis more than 5 minutes from now, so an old message can't be replayed.
js
import crypto from "node:crypto";
import express from "express";
const secret = Buffer.from(process.env.FOB_WEBHOOK_SECRET.replace(/^whsec_/, ""), "base64");
function verify(headers, body) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!id || !timestamp || !signatures) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${id}.${timestamp}.`).update(body).digest();
return signatures.split(" ").some((entry) => {
const [version, signature] = entry.split(",");
const given = Buffer.from(signature ?? "", "base64");
return version === "v1" && given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}
const app = express();
app.post("/webhooks/fob", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.headers, req.body)) return res.status(401).end();
const event = JSON.parse(req.body.toString("utf8"));
console.log(event.type, event.data.id);
res.status(204).end();
});python
import base64
import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
secret = base64.b64decode(os.environ["FOB_WEBHOOK_SECRET"].removeprefix("whsec_"))
def verify(headers, body: bytes) -> bool:
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp", "")
signatures = headers.get("webhook-signature")
if not (msg_id and timestamp.isdigit() and signatures):
return False
if abs(time.time() - int(timestamp)) > 300:
return False
signed = f"{msg_id}.{timestamp}.".encode() + body
expected = base64.b64encode(hmac.new(secret, signed, hashlib.sha256).digest()).decode()
for entry in signatures.split(" "):
version, _, signature = entry.partition(",")
if version == "v1" and hmac.compare_digest(signature, expected):
return True
return False
app = Flask(__name__)
@app.post("/webhooks/fob")
def fob_webhook():
if not verify(request.headers, request.get_data()):
abort(401)
event = request.get_json()
print(event["type"], event["data"]["id"])
return "", 204Test your endpoint
Choose Send test on the endpoint's page. Fob sends a webhook.test message, whatever events the endpoint asked for. Its data has project, your organization's slug, and message.
The endpoint's page lists its 50 latest messages with their status (Queued, Retrying, Delivered or Failed), the payload, and the last response. Choose Resend to send a message again, with the same webhook-id. Messages are kept for 30 days.
Message format
Each Standard message is a POST with a JSON body:
json
{
"type": "user.disabled",
"timestamp": "2026-11-09T14:03:12.481Z",
"data": {
"id": "0193f3c4-a1b2-7c7e-9d0e-5b3f8a6c2d41",
"actor_id": "0193e8a0-55c1-7d2a-b6f4-1c9e0a7d3b58",
"email": "ada@acme.com"
}
}data.id is the thing that changed. data.actor_id is the admin or user who changed it, or null for changes made with an API key or SCIM. Other fields depend on the event.
| Header | Value |
|---|---|
webhook-id | The message ID, like msg_0193f3c4a1b27c7e9d0e5b3f8a6c2d41. The same on every retry. |
webhook-timestamp | When this attempt was sent, in Unix seconds |
webhook-signature | v1, followed by the base64 signature |
user-agent | Fob-Webhooks/1.0 |
Retries
Return a 2xx status within 15 seconds. Anything else counts as a failure, including a redirect, and Fob retries with backoff: 10 attempts in total over about three days.
Retry schedule
After the first attempt, Fob retries after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, 14 hours, 20 hours and 24 hours.
Slack and Discord messages that the service refuses with a 4xx status, other than 408 or 429, are marked Failed straight away and not retried.
To stop messages for good, return 410 Gone. Fob turns the endpoint off.
When Fob turns an endpoint off
Fob turns an endpoint off if every message to it has failed for five days, with at least ten failures in a row, or straight away if it returns 410 Gone. Fob emails the organization's owners and admins and sends a webhook.disabled event. Fix the endpoint, then choose Turn on.
Pausing an endpoint
Choose Pause endpoint in the endpoint's menu to stop sending to it.
Warning
Changes made while an endpoint is paused are never sent to it. Messages waiting when you paused it are marked Failed. Use Resend for any you still need. Deleting an endpoint drops its waiting messages.
Best practices
- Skip duplicates. A message can arrive more than once. Store each
webhook-idyou've handled and ignore repeats. - Don't rely on order. Messages can arrive out of order. Use the body's
timestampif order matters, or fetch the current state from the management API. - Respond quickly. Return 2xx as soon as you've verified and stored the message, and do slow work afterwards, so you stay inside the 15 seconds.
- Treat the URL as a secret. A Slack or Discord URL is all anyone needs to post to your channel, and other URLs often carry a token. Fob stores the URL encrypted and never shows it again in full. To change it, enter the whole new URL.
Events
| Event | When |
|---|---|
session.created | A user signed in. data has user_id, email, ip and method: password, passkey, sso or email_link, followed by +totp, +passkey or +recovery_code when they used a second factor. data.id is the session. |
user.invited | A user was invited |
user.created, user.provisioned | A user was created with a password, or by SCIM or an identity provider |
user.updated, user.attributes_updated | A user's name, email or profile attributes changed |
user.email_added, user.email_removed, user.primary_email_changed | An admin added or removed one of a user's addresses, or changed their primary address |
user.disabled, user.enabled, user.deleted | A user was disabled, enabled again or deleted |
user.email_verified | An admin marked a user's email as confirmed |
user.signed_out_everywhere, user.session_revoked | An admin ended a user's sessions |
user.signed_out_other_devices | A user signed out their other devices |
user.mfa_reset, user.unlocked | An admin reset a user's second factors, or unlocked their account |
user.password_reset_sent | An admin sent a password reset |
user.impersonation_started, user.impersonation_ended | An admin impersonated a user |
group.created, group.renamed, group.deleted | A group was created, renamed through SCIM, or deleted. group.renamed has from and name. |
group.member_added, group.member_removed | A user joined or left a group. data.id is the user and data.group the group. |
application.created, application.updated, application.deleted | An app changed |
application.secret_rotated, application.assigned, application.unassigned | An app's secret was rotated, or its assignments changed |
sign_on_policy.created, sign_on_policy.updated, sign_on_policy.deleted | A sign-on policy or its rules changed |
network.saved, network.deleted | A network changed |
connection.created, connection.updated, connection.deleted | An identity provider changed |
domain.added, domain.verified, domain.removed, domain.primary_set, domain.lapsed | A domain changed. domain.lapsed means its DNS record was missing for three days and it's no longer verified. |
keys.rotated | A signing key was rotated. immediately is true for an emergency replacement. |
profile.attribute_saved, profile.attribute_deleted | A profile attribute was added, changed or deleted |
api_key.created, api_key.revoked | An API key was created or revoked |
webhook.created, webhook.updated, webhook.deleted | A webhook endpoint changed. webhook.updated lists what changed among url, events, enabled and description, each with from and to. URLs are shown without their secret parts. |
webhook.disabled | Fob turned an endpoint off. Has url, failing_since, failures and reason: failing or gone. |
project.updated, project.password_policy_updated, project.session_policy_updated | The organization's settings changed |
webhook.test | Sent by Send test |
Five events can't be picked individually: user.email_added, user.email_removed, user.primary_email_changed, user.session_revoked and user.password_reset_sent. To get them, turn on Send every event. Those endpoints also get every other change recorded in the audit log, under the same names, such as project.logo_changed.
Changes to admins and to Access aren't sent as webhooks.