Skip to content

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 ​

  1. Open Settings > Webhooks and choose Add endpoint.

  2. Choose a format:

    FormatSendsURL
    StandardSigned JSON, following Standard WebhooksAny public https URL
    SlackA formatted message for a channelA Slack incoming webhook, https://hooks.slack.com/services/...
    DiscordA formatted message for a channelA Discord webhook, https://discord.com/api/webhooks/...
  3. Enter the URL and an optional description.

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

  1. Compute an HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body}, keyed with the signing secret after removing whsec_ and base64-decoding it. Use the raw body, before parsing the JSON.
  2. Compare it, in constant time, with each space-separated v1, entry in the webhook-signature header.
  3. Reject messages whose webhook-timestamp is 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 "", 204

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

HeaderValue
webhook-idThe message ID, like msg_0193f3c4a1b27c7e9d0e5b3f8a6c2d41. The same on every retry.
webhook-timestampWhen this attempt was sent, in Unix seconds
webhook-signaturev1, followed by the base64 signature
user-agentFob-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-id you've handled and ignore repeats.
  • Don't rely on order. Messages can arrive out of order. Use the body's timestamp if 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 ​

EventWhen
session.createdA 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.invitedA user was invited
user.created, user.provisionedA user was created with a password, or by SCIM or an identity provider
user.updated, user.attributes_updatedA user's name, email or profile attributes changed
user.email_added, user.email_removed, user.primary_email_changedAn admin added or removed one of a user's addresses, or changed their primary address
user.disabled, user.enabled, user.deletedA user was disabled, enabled again or deleted
user.email_verifiedAn admin marked a user's email as confirmed
user.signed_out_everywhere, user.session_revokedAn admin ended a user's sessions
user.signed_out_other_devicesA user signed out their other devices
user.mfa_reset, user.unlockedAn admin reset a user's second factors, or unlocked their account
user.password_reset_sentAn admin sent a password reset
user.impersonation_started, user.impersonation_endedAn admin impersonated a user
group.created, group.renamed, group.deletedA group was created, renamed through SCIM, or deleted. group.renamed has from and name.
group.member_added, group.member_removedA user joined or left a group. data.id is the user and data.group the group.
application.created, application.updated, application.deletedAn app changed
application.secret_rotated, application.assigned, application.unassignedAn app's secret was rotated, or its assignments changed
sign_on_policy.created, sign_on_policy.updated, sign_on_policy.deletedA sign-on policy or its rules changed
network.saved, network.deletedA network changed
connection.created, connection.updated, connection.deletedAn identity provider changed
domain.added, domain.verified, domain.removed, domain.primary_set, domain.lapsedA domain changed. domain.lapsed means its DNS record was missing for three days and it's no longer verified.
keys.rotatedA signing key was rotated. immediately is true for an emergency replacement.
profile.attribute_saved, profile.attribute_deletedA profile attribute was added, changed or deleted
api_key.created, api_key.revokedAn API key was created or revoked
webhook.created, webhook.updated, webhook.deletedA 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.disabledFob turned an endpoint off. Has url, failing_since, failures and reason: failing or gone.
project.updated, project.password_policy_updated, project.session_policy_updatedThe organization's settings changed
webhook.testSent 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.