INTEGRATION GUIDE • MARCH 28, 2024

From Polling to Push: Mastering Real-Time Events with Webhooks

Stop polling the API. Learn to use webhooks to have your application notified instantly when a bot's state changes or a transcript is updated.

Building a responsive application requires knowing when things happen. Instead of constantly polling an API to check for status updates (e.g., "Is my transcript ready yet?"), webhooks allow Attendee to proactively send you real-time notifications as soon as an event occurs. This is more efficient, faster, and scalable.

Understanding Webhook Events

Attendee currently supports two primary webhook triggers. You can subscribe to one or both depending on your application's needs.

  1. bot.state_change: Fires whenever a bot's lifecycle state changes (e.g., from joining to joined_recording). This is ideal for tracking a bot's progress and knowing when a session is complete.
  2. transcript.update: Fires in real-time as new utterances are transcribed. This is essential for building applications that display live transcripts.

You can configure these in your Attendee dashboard under Settings → Webhooks. You'll need to provide a secure HTTPS URL for your server and will receive a unique signing secret to validate incoming requests.

Payload Deep Dive: `bot.state_change`

Here's what the payload looks like when a bot finishes its work and the transcript is ready. This is the most common use case for this trigger.

Webhook Payload: bot.state_change
{
  "idempotency_key": "550e8400-e29b-41d4-a716-446655440000",
  "bot_id": "bot_3hfP0PXEsNinIZmh",
  "trigger": "bot.state_change",
  "data": {
    "new_state": "ended",
    "old_state": "post_processing",
    "event_type": "post_processing_completed"
  }
}

The key field here is event_type: "post_processing_completed". This is your definitive signal that all processing is done and artifacts (like the full transcript) are available to be fetched via the API.

Payload Deep Dive: `transcript.update`

For real-time transcription, you'll receive a payload for each new utterance. It contains the speaker's name, the text, and precise timing information.

Webhook Payload: transcript.update
{
  "idempotency_key": "671f9a1c-74b8-4791-881c-9141ac9b80c3",
  "bot_id": "bot_3hfP0PXEsNinIZmh",
  "trigger": "transcript.update",
  "data": {
    "speaker_name": "Jane Doe",
    "timestamp_ms": 1679589132000,
    "duration_ms": 3200,
    "transcription": {
        "transcript": "Okay, thanks everyone for joining."
    }
  }
}

Secure Webhook Receivers: Code Examples

It is critical to verify that incoming webhooks are genuinely from Attendee. We achieve this by signing each webhook payload with your unique secret key. Below are robust examples in Python and Node.js that can receive webhooks, validate their signatures, and process both event types.

webhook_server.py
import json
import hmac
import hashlib
import base64
import os
from flask import Flask, request, abort

app = Flask(__name__)

# Load your secret from an environment variable for security
WEBHOOK_SECRET = os.environ.get("ATTENDEE_WEBHOOK_SECRET")

def sign_payload(payload_json_string, secret_b64):
    """Signs a canonical JSON string and returns a Base64-encoded signature."""
    secret_decoded = base64.b64decode(secret_b64)
    signature = hmac.new(
        secret_decoded,
        payload_json_string.encode("utf-8"),
        hashlib.sha256
    ).digest()
    return base64.b64encode(signature).decode("utf-8")

@app.route("/webhook-receiver", methods=["POST"])
def webhook():
    if not WEBHOOK_SECRET:
        abort(500, "Webhook secret not configured.")

    # 1. Get payload and signature from the request
    payload_body = request.data
    signature_from_header = request.headers.get("X-Webhook-Signature")

    if not signature_from_header:
        abort(400, "Missing signature header")

    # 2. Re-create the canonical JSON string from the payload
    try:
        payload_dict = json.loads(payload_body)
        payload_json_string = json.dumps(payload_dict, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
    except json.JSONDecodeError:
        abort(400, "Invalid JSON payload")

    # 3. Create our own signature and securely compare it
    expected_signature = sign_payload(payload_json_string, WEBHOOK_SECRET)
    if not hmac.compare_digest(expected_signature, signature_from_header):
        abort(400, "Invalid signature")
    
    # 4. Process the verified payload
    print(f"Received verified webhook for trigger: {payload_dict['trigger']}")
    if payload_dict["trigger"] == "bot.state_change":
        handle_state_change(payload_dict)
    elif payload_dict["trigger"] == "transcript.update":
        handle_transcript_update(payload_dict)

    # 5. Respond quickly with a 200 OK
    return "Webhook received successfully", 200

def handle_state_change(payload):
    bot_id = payload["bot_id"]
    if payload["data"].get("event_type") == "post_processing_completed":
        print(f"Bot {bot_id} has finished. Fetching final transcript now...")
        # Trigger background job: GET /v1/bots/{bot_id}/transcript

def handle_transcript_update(payload):
    utterance = payload["data"]
    print(f"Real-time update: [{utterance['speaker_name']}] {utterance['transcription']['transcript']}")
    # Push this utterance to your frontend via WebSockets, SSE, etc.

if __name__ == "__main__":
    app.run(port=5001, debug=True)
webhook_server.js
import express from "express";
import crypto from "crypto";

const app = express();
const port = 5005;

// Load your secret from an environment variable for security
const WEBHOOK_SECRET = process.env.ATTENDEE_WEBHOOK_SECRET;

// Middleware to parse raw body for signature verification
app.use(express.json({
    verify: (req, res, buf) => {
        req.rawBody = buf.toString();
    }
}));

function sortKeys(value) {
  if (Array.isArray(value)) return value.map(sortKeys);
  if (value && typeof value === "object" && !(value instanceof Date)) {
    return Object.keys(value).sort().reduce((acc, k) => ({ ...acc, [k]: sortKeys(value[k]) }), {});
  }
  return value;
}

function signPayload(payload, secretB64) {
  const canonical = JSON.stringify(sortKeys(payload));
  const secretBuf = Buffer.from(secretB64, "base64");
  return crypto.createHmac("sha256", secretBuf).update(canonical, "utf8").digest("base64");
}

function handleStateChange(payload) {
    const botId = payload.bot_id;
    if (payload.data?.event_type === "post_processing_completed") {
        console.log(`Bot ${botId} has finished. Fetching final transcript now...`);
        // Trigger background job: GET /v1/bots/${botId}/transcript
    }
}

function handleTranscriptUpdate(payload) {
    const utterance = payload.data;
    console.log(`Real-time update: [${utterance.speaker_name}] ${utterance.transcription.transcript}`);
    // Push this utterance to your frontend via WebSockets, SSE, etc.
}

app.post("/webhook-receiver", (req, res) => {
    if (!WEBHOOK_SECRET) {
        return res.status(500).send("Webhook secret not configured.");
    }

    const payload = req.body;
    const signatureFromHeader = req.header("X-Webhook-Signature") || "";

    const signatureCalculated = signPayload(payload, WEBHOOK_SECRET);

    const isVerified = crypto.timingSafeEqual(
        Buffer.from(signatureCalculated, 'utf8'), 
        Buffer.from(signatureFromHeader, 'utf8')
    );

    if (!isVerified) {
        return res.status(400).send("Invalid signature");
    }

    console.log(`Received verified webhook for trigger: ${payload.trigger}`);
    if (payload.trigger === 'bot.state_change') {
        handleStateChange(payload);
    } else if (payload.trigger === 'transcript.update') {
        handleTranscriptUpdate(payload);
    }

    res.status(200).send("Webhook received successfully");
});

app.listen(port, () => {
    console.log(`Webhook server running at http://localhost:${port}`);
});

Best Practices for Production

Explore Further

Our official documentation includes an exhaustive list of all webhook triggers, event types, and data schemas.

View Full Webhook Documentation