I'm trying to create a webhook for just the room f...
# ask-ai
f
I'm trying to create a webhook for just the room finished event to feed to a lambda. I can't figure out how to configure my webhook settings to only emit that specific event to my lambda so I don't invoke it a bunch of excess times with all the other events. Is this possible through either the livekit dashboard or the sdk?
t
Thanks for reaching out, Akash. I'm an AI assistant and I'm here to help. Give me a moment while I research this topic.
Thanks — I understand you want your Lambda invoked only for the
room_finished
event and not for other webhook events. Yes — you can filter webhooks so only
room_finished
is sent. Two ways: • LiveKit Cloud Dashboard: Project → Settings → Webhooks. When adding/editing the webhook, uncheck all events except
room_finished
, then save. Only
room_finished
events will be sent to your Lambda. • Self-hosted / config file: add
event_filters
in your server config:
Copy code
webhook:
  urls:
    - <https://your-lambda-endpoint.com/webhook>
  event_filters:
    - room_finished
This sends only
room_finished
to that URL. Note: Server SDK webhook receivers that accept all events will require you to filter in code; use the dashboard or config filtering to avoid extra Lambda invocations. If you’d like, tell me whether you’re using LiveKit Cloud or a self-hosted instance and I’ll give exact Dashboard steps or a ready-to-use config snippet and a quick note on validating the webhook in Lambda. Sources: Webhooks | LiveKit docs | server-sdk-js | livekit/livekit_composite | DeepWiki
f
so im using the server sdk webhook, this is the lambda I want to be invoked via api gateway. I only want this lambda to be triggered for the room finished events import os import json import logging import re import base64 import hashlib import psycopg from dotenv import load_dotenv from livekit import api as lk_api from twilio.rest import Client as TwilioClient # --- CONFIGURATION --- # Load .env file if it exists (Optional) load_dotenv() # Fetch Variables DATABASE_URL = os.environ.get("DATABASE_URL") LIVEKIT_API_KEY = os.environ.get("LIVEKIT_API_KEY") LIVEKIT_API_SECRET = os.environ.get("LIVEKIT_API_SECRET") TWILIO_ACCOUNT_SID = os.environ.get("TWILIO_ACCOUNT_SID") TWILIO_AUTH_TOKEN = os.environ.get("TWILIO_AUTH_TOKEN") TWILIO_FROM = os.environ.get("TWILIO_PHONE_NUMBER") NOTIFY_NUMBERS = [''] logger = logging.getLogger() logger.setLevel(logging.INFO) # Initialize Verifier (Initialize globally to reuse session) try: token_verifier = lk_api.TokenVerifier(LIVEKIT_API_KEY, LIVEKIT_API_SECRET) except Exception as e: logger.error(f"Failed to init LiveKit Verifier. Check LIVEKIT_API_KEY/SECRET. Error: {e}") token_verifier = None def lambda_handler(event, context): try: # --- DEBUG SECTION: CHECK ENV VARS --- # This will print which keys exist (masked) to help find typos env_keys = [k for k in os.environ.keys() if "TWILIO" in k or "LIVEKIT" in k] logger.info(f"DEBUG: Found Environment Variables: {env_keys}") if not (TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN and TWILIO_FROM): missing = [] if not TWILIO_ACCOUNT_SID: missing.append("TWILIO_ACCOUNT_SID") if not TWILIO_AUTH_TOKEN: missing.append("TWILIO_AUTH_TOKEN") if not TWILIO_FROM: missing.append("TWILIO_PHONE_NUMBER") logger.error(f"CRITICAL: Missing Twilio Credentials: {', '.join(missing)}") # ------------------------------------- headers = event.get("headers", {}) # 1. Normalize Headers auth_header = None for key, value in headers.items(): if key.lower() == "authorization": auth_header = value break if not auth_header: logger.warning("Missing Authorization header") return {"statusCode": 401, "body": "Missing Authorization"} # 2. Robust Body Handling (Raw Bytes) raw_body_bytes = b"" if event.get("isBase64Encoded", False): raw_body_bytes = base64.b64decode(event.get("body", "")) else: # If standard string, encode to bytes raw_body_bytes = event.get("body", "").encode("utf-8") # 3. Calculate Local Hash local_hash = hashlib.sha256(raw_body_bytes).hexdigest() # 4. Verify Token & Compare Hash (Manual Mode) if not token_verifier: return {"statusCode": 500, "body": "Server Configuration Error"} try: # Verify the JWT is valid and signed by us claims = token_verifier.verify(auth_header) # Compare the hash inside the token with our local hash if claims.sha256 != local_hash: logger.error(f"Hash Mismatch! Token: {claims.sha256} vs Local: {local_hash}") return {"statusCode": 401, "body": "Hash Mismatch"} except Exception as e: logger.error(f"Token verification failed: {e}") return {"statusCode": 401, "body": "Invalid Token"} # 5. Parse JSON manually try: body_json = json.loads(raw_body_bytes) except Exception as e: logger.error(f"Failed to parse JSON body: {e}") return {"statusCode": 400, "body": "Invalid JSON"} # 6. Process Logic event_type = body_json.get("event") if event_type == "room_finished": room_data = body_json.get("room", {}) room_name = room_data.get("name") if room_name: logger.info(f"Processing room_finished for {room_name}") match = re.search(r"-(\d+)$", room_name) if match: call_id = int(match.group(1)) check_and_kill_zombie(call_id, room_name) else: logger.warning(f"Could not parse Call ID from room: {room_name}") else: logger.warning("Room name missing in payload") return {"statusCode": 200, "body": "ok"} except Exception as e: logger.error(f"Internal Server Error: {e}", exc_info=True) return {"statusCode": 500, "body": "Internal Error"} def check_and_kill_zombie(call_id, room_name): try: with psycopg.connect(DATABASE_URL) as conn: with conn.cursor() as cur: cur.execute("SELECT status, provider_id FROM call_sessions WHERE id = %s", (call_id,)) row = cur.fetchone() if not row: logger.warning(f"Call {call_id} not found in DB") return current_status, provider_id = row # Check if stuck if current_status in ['in_progress', 'connecting']: logger.error(f"ZOMBIE DETECTED: Call {call_id} stuck in '{current_status}'") cur.execute(""" UPDATE call_sessions SET status = 'failed', ended_at = NOW(), outcome = 'Technical Error', updated_at = NOW() WHERE id = %s """, (call_id,)) conn.commit() send_sms_alert(call_id, provider_id, current_status) else: logger.info(f"Call {call_id} is healthy (Status: {current_status}). No action needed.") except Exception as e: logger.error(f"DB Error for call {call_id}: {e}") def send_sms_alert(call_id, provider_id, stuck_status): # This check is redundant with the handler check, but good for safety if not (TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN and TWILIO_FROM): logger.error("Cannot send SMS: Credentials missing (see handler logs for details)") return try: client = TwilioClient(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN) msg_body = ( f"Zombie Call Session:\n" f"Call ID: {call_id}\n" f"Provider: {provider_id}\n" f"Was stuck in: '{stuck_status}'\n" f"Action: Set to 'failed' via Webhook." ) for number in NOTIFY_NUMBERS: client.messages.create(body=msg_body, from_=TWILIO_FROM, to=number) logger.info(f"Sent Zombie SMS alerts for Call {call_id}") except Exception as e: logger.error(f"Failed to send Zombie SMS: {e}")
t
Thanks — I see you’re using the server SDK webhook receiver and want your Lambda (via API Gateway) invoked only for
room_finished
. Short answer: the server SDK webhook receiver will accept all LiveKit events; you cannot make the SDK itself pre-filter events before they arrive. Your Lambda must verify the request and then check the event type (exactly what your code already does). What you’re doing correctly • Verifying the JWT with
TokenVerifier
and comparing the SHA256 hash of the raw body. • Parsing the raw body bytes and JSON only after verification. • Branching on
event_type == "room_finished"
to run your cleanup logic and returning quickly for other events. Optional improvements / best practices • Return 200 quickly for non-matching events to minimize Lambda execution time (you already do this by only running DB/Twilio when
room_finished
). • Keep the JWT verification and hash comparison as a fast gate before any DB/Twilio calls. • If you expect a high volume of non-room events and want to avoid Lambda cold-starts or costs, consider: ◦ Using a lightweight proxy endpoint (e.g., small HTTP function) that verifies and filters, then forwards only
room_finished
to your heavier Lambda. ◦ If you self-host LiveKit, configure
event_filters
in your LiveKit server config so the webhook URL receives only
room_finished
(example snippet):
Copy code
webhook:
      urls:
        - <https://your-api-gateway-url/webhook>
      event_filters:
        - room_finished
This prevents non-room events from being sent to the endpoint at the source. If you’d like, tell me whether you want: • a minimal proxy example to forward only
room_finished
to your Lambda, or • an exact
config.yaml
snippet for self-hosted LiveKit, or • a small optimization to reduce Lambda runtime (e.g., faster early return pattern). Sources: Webhooks | LiveKit docs | examples/survey | livekit/livekit_composite | DeepWiki | Server-side | livekit/livekit_composite | DeepWiki
f
yes can you help me revise this lambda so it returns early for non room_finished event, and make sure its following the proper syntax for what the webhook body contains.
t
Thanks — I’ll update your Lambda to: • Verify the JWT and payload hash as you do now. • Parse the webhook body using the expected LiveKit webhook shape (top-level
event
string and
room
object). • Immediately return 200 for any event that is not
room_finished
to minimize work and cost. • Keep logging useful debug info. Below is a revised, minimal version of your handler with those changes. It preserves your verification steps and performs an early return for non-matching events.
Copy code
import os
import json
import logging
import base64
import hashlib
import re
import psycopg
from dotenv import load_dotenv
from livekit import api as lk_api
from twilio.rest import Client as TwilioClient

load_dotenv()

DATABASE_URL = os.environ.get("DATABASE_URL")
LIVEKIT_API_KEY = os.environ.get("LIVEKIT_API_KEY")
LIVEKIT_API_SECRET = os.environ.get("LIVEKIT_API_SECRET")
TWILIO_ACCOUNT_SID = os.environ.get("TWILIO_ACCOUNT_SID")
TWILIO_AUTH_TOKEN = os.environ.get("TWILIO_AUTH_TOKEN")
TWILIO_FROM = os.environ.get("TWILIO_PHONE_NUMBER")
NOTIFY_NUMBERS = ['']

logger = logging.getLogger()
logger.setLevel(<http://logging.INFO|logging.INFO>)

try:
    token_verifier = lk_api.TokenVerifier(LIVEKIT_API_KEY, LIVEKIT_API_SECRET)
except Exception as e:
    logger.error(f"Failed to init LiveKit Verifier: {e}")
    token_verifier = None

def lambda_handler(event, context):
    try:
        headers = event.get("headers", {}) or {}
        # find Authorization header case-insensitively
        auth_header = None
        for k, v in headers.items():
            if k.lower() == "authorization":
                auth_header = v
                break
        if not auth_header:
            logger.warning("Missing Authorization header")
            return {"statusCode": 401, "body": "Missing Authorization"}

        # raw body bytes for hash verification
        if event.get("isBase64Encoded", False):
            raw_body_bytes = base64.b64decode(event.get("body", "") or "")
        else:
            raw_body_bytes = (event.get("body") or "").encode("utf-8")

        # local sha256 hex
        local_hash = hashlib.sha256(raw_body_bytes).hexdigest()

        if not token_verifier:
            logger.error("Token verifier not initialized")
            return {"statusCode": 500, "body": "Server Configuration Error"}

        # Verify token and compare payload hash
        try:
            claims = token_verifier.verify(auth_header)
            if getattr(claims, "sha256", None) != local_hash:
                logger.error("Hash Mismatch")
                return {"statusCode": 401, "body": "Hash Mismatch"}
        except Exception as e:
            logger.error(f"Token verification failed: {e}")
            return {"statusCode": 401, "body": "Invalid Token"}

        # parse JSON body
        try:
            body_json = json.loads(raw_body_bytes)
        except Exception as e:
            logger.error(f"Invalid JSON body: {e}")
            return {"statusCode": 400, "body": "Invalid JSON"}

        # Expected webhook shape: { "event": "room_finished", "room": { ... }, ... }
        event_type = body_json.get("event")
        if event_type != "room_finished":
            # Early return for all other events
            logger.debug(f"Ignoring event: {event_type}")
            return {"statusCode": 200, "body": "ignored"}

        # Handle room_finished
        room = body_json.get("room", {}) or {}
        room_name = room.get("name")
        if not room_name:
            logger.warning("room_finished payload missing room.name")
            return {"statusCode": 200, "body": "no room name"}

        <http://logger.info|logger.info>(f"Processing room_finished for {room_name}")
        match = re.search(r"-(\d+)$", room_name)
        if not match:
            logger.warning(f"Could not parse Call ID from room name: {room_name}")
            return {"statusCode": 200, "body": "no call id"}

        call_id = int(match.group(1))
        check_and_kill_zombie(call_id, room_name)
        return {"statusCode": 200, "body": "ok"}

    except Exception as e:
        logger.error(f"Internal Server Error: {e}", exc_info=True)
        return {"statusCode": 500, "body": "Internal Error"}

def check_and_kill_zombie(call_id, room_name):
    try:
        with psycopg.connect(DATABASE_URL) as conn:
            with conn.cursor() as cur:
                cur.execute("SELECT status, provider_id FROM call_sessions WHERE id = %s", (call_id,))
                row = cur.fetchone()
                if not row:
                    logger.warning(f"Call {call_id} not found")
                    return
                current_status, provider_id = row
                if current_status in ['in_progress', 'connecting']:
                    logger.error(f"ZOMBIE DETECTED: Call {call_id} stuck in '{current_status}'")
                    cur.execute("""
                        UPDATE call_sessions
                        SET status = 'failed',
                            ended_at = NOW(),
                            outcome = 'Technical Error',
                            updated_at = NOW()
                        WHERE id = %s
                    """, (call_id,))
                    conn.commit()
                    send_sms_alert(call_id, provider_id, current_status)
                else:
                    <http://logger.info|logger.info>(f"Call {call_id} healthy (Status: {current_status})")
    except Exception as e:
        logger.error(f"DB Error for call {call_id}: {e}")

def send_sms_alert(call_id, provider_id, stuck_status):
    if not (TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN and TWILIO_FROM):
        logger.error("Cannot send SMS: credentials missing")
        return
    try:
        client = TwilioClient(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN)
        msg_body = (
            f"Zombie Call Session:\n"
            f"Call ID: {call_id}\n"
            f"Provider: {provider_id}\n"
            f"Was stuck in: '{stuck_status}'\n"
            f"Action: Set to 'failed' via Webhook."
        )
        for number in NOTIFY_NUMBERS:
            client.messages.create(body=msg_body, from_=TWILIO_FROM, to=number)
        <http://logger.info|logger.info>(f"Sent Zombie SMS alerts for Call {call_id}")
    except Exception as e:
        logger.error(f"Failed to send Zombie SMS: {e}")
Notes: • The LiveKit webhook uses top-level
event
and a
room
object; this code reads
body_json["event"]
and
body_json["room"]["name"]
. • Early return happens right after checking
event_type != "room_finished"
, returning 200 quickly which minimizes processing and cost. • If you want a lighter-weight pre-filter, I can provide a tiny proxy function that only forwards
room_finished
to this Lambda.
f
so i need the room name (e.g. agent-type-number) because the number bit corresponds to a call id i need ot use to update the status of the call session. Its tied to the room name (e,g, Room name chronic-care-mgmt-2395 ) will your update to the lambda still retrieve that
t
Yes — the revised Lambda still retrieves the room name and extracts the numeric call ID exactly as you need. What it does: • Reads the webhook JSON’s top-level
event
field and returns early unless
event == "room_finished"
. • For
room_finished
, it reads
body_json["room"]["name"]
(handles missing keys safely). • Uses the same regex you provided,
r"-(\d+)$"
, to pull the trailing number from names like
chronic-care-mgmt-2395
and converts it to an int for DB use. So for a room named
chronic-care-mgmt-2395
the handler will set
call_id = 2395
and call
check_and_kill_zombie(2395, "chronic-care-mgmt-2395")
as before. If you want, I can: • Change the regex if your room naming varies, or • Add extra validation/logging around the parsed call_id before DB updates. Which would you prefer?
f
so this current lambda is deployed, webhook set to this url, https://04iy6g08m9.execute-api.us-east-1.amazonaws.com/default/LivekitWebhookCallStateMgrLambda if i create this dummy payload my lambda triggers for the room_finished, but the actual livekit webhook i ahve setup doesn't seem to be doing anything, is there any way to check the events its sending out # --------------------- def *test_zombie_kill*(): # 1. Create Payload payload = { "event": "room_finished", "createdAt": 1700000000, "id": "evt_test123", "room": { "sid": "RM_TEST123", "name": f"chronic-care-mgmt-{CALL_ID}", "emptyTimeout": 300, "maxParticipants": 2, "creationTime": 1700000000, "metadata": "{}" } } # 2. SERIALIZE TO BYTES (Crucial Step) # separators=(',', ':') removes spaces. This is the standard for webhooks. body_str = json.dumps(payload, separators=(',', ':')) body_bytes = body_str.encode('utf-8') # 3. Calculate Hash body_hash = hashlib.sha256(body_bytes).hexdigest() print(f"🔹 Payload Body: {body_str}") print(f"🔹 Computed SHA256: {body_hash}") # 4. Sign token = lk_api.AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET) \ .with_sha256(body_hash) \ .to_jwt() # 5. Send print(f"🔫 Firing 'room_finished' webhook...") _# Note: We send 'data=body_bytes' to ensure requests doesn't alter encoding_ response = requests.post( WEBHOOK_URL, data=body_bytes, headers={ "Authorization": token, "Content-Type": "application/webhook+json" } ) if response.status_code == 200: print("✅ Success! Webhook accepted.") _else_: print(f"❌ Failed: {response.status_code}") print(response.text) if name == "__main__": test_zombie_kill()
t
Thanks — I hear you: your manual test POST hits the Lambda, but the LiveKit-managed webhook doesn't appear to be invoking it. I’ll outline targeted checks and next steps to find what LiveKit is sending and why your deployed webhook URL may not be receiving events. Quick clarifying question • Are you using LiveKit Cloud (cloud.livekit.io) or a self-hosted LiveKit server? This changes where to check webhook delivery and event_filters. Checks to run now 1. Confirm webhook URL in LiveKit • In LiveKit Cloud: Project → Settings → Webhooks and verify the URL exactly matches your API Gateway URL (including path and stage). • If self-hosted: confirm
webhook.urls
in your
config.yaml
contains the exact API Gateway URL. 1. Confirm event filtering • If Cloud: ensure
room_finished
is checked/enabled for that webhook (or leave all enabled while debugging). • If self-hosted: confirm
event_filters
isn’t excluding
room_finished
. 1. Check LiveKit delivery logs (Cloud) • In the Cloud dashboard Webhooks section you can view recent delivery attempts and errors. Look for requests to your API Gateway and any failure codes or error messages. 1. Check API Gateway + Lambda logs • API Gateway: check CloudWatch request logs for the stage to see whether requests arrive and are rejected by API Gateway (e.g., 403, 404, 502). • Lambda: check CloudWatch logs for your Lambda function for any incoming request logs. Your manual test shows it logs; look for entries with timestamps matching LiveKit events. 1. Validate the webhook request shape and headers • LiveKit sends a JWT in the Authorization header and uses
application/webhook+json
. Your handler handles these; confirm API Gateway is forwarding the Authorization header to Lambda (APIGW sometimes drops headers if not configured). ◦ In API Gateway, ensure the Authorization header is included in your method’s mapping/templates or that you’re using a Lambda Proxy integration (which forwards headers automatically). 1. Confirm LiveKit can reach your API Gateway • If your API Gateway requires an API key, IAM auth, custom authorizer, or is private (VPC endpoint), LiveKit will not reach it. Ensure the endpoint is publicly reachable and accepts simple POST with Authorization header. 1. Reproduce with a signed, LiveKit-like request • You already built a correct test that signs the payload. One useful check: send that same signed test from an external host (not your dev machine behind firewall) to confirm API Gateway + Lambda behave the same from the public internet. If you want, I can provide: • A small script you can run to poll LiveKit Cloud webhook delivery logs (if you confirm you’re on LiveKit Cloud), or • A step-by-step checklist for verifying API Gateway mapping and header forwarding, or • A minimal curl example that simulates LiveKit’s request (signed JWT + content-type) for you to run publicly. Tell me whether you are on LiveKit Cloud or self-hosted and which option above you want next and I’ll give the exact commands and checks.
f
im using cloud.livekit.io. but i don't see anywhere to enable specific events in here https://cloud.livekit.io/projects/p_3n4929oy90q/settings/webhooks, any way usign the lk cli to modifyt he webhook? Webhooks LiveKit can be configured to notify your server when room events take place. This can be helpful for your backend to know when a room has finished, or when a participant leaves. For information about how to set up webhooks check out our documentation. Zombie Call Session Observer https://04iy6g08m9.execute-api.us-east-1.amazonaws.com/default/LivekitWebhookCallStateMgrLambda· Signed with
t
Thanks — you’re on LiveKit Cloud. I checked: the Cloud dashboard doesn’t expose per-event toggles in that UI, but you can configure event filters via the LiveKit CLI so the Cloud project only sends the
room_finished
event to your webhook URL. Do you have the LiveKit CLI (
lk
) installed and authenticated to your Cloud project? If so, run one of these commands to create or update the webhook to only send `room_finished`: • Create webhook:
Copy code
lk webhook create \
  --project p_3n4929oy90q \
  --url <https://04iy6g08m9.execute-api.us-east-1.amazonaws.com/default/LivekitWebhookCallStateMgrLambda> \
  --events room_finished \
  --signed
• Update existing webhook (use the webhook id from
lk webhook list
):
Copy code
lk webhook update WEBHOOK_ID \
  --events room_finished
Notes: •
--signed
ensures LiveKit signs the webhook with the project API key (your Lambda verifies this). • After updating, LiveKit Cloud will only send
room_finished
to that URL, preventing other events from invoking your Lambda. • The dashboard will still show the webhook URL but not the event list — use
lk webhook list
and
lk webhook get WEBHOOK_ID
to confirm the webhook’s
events
value. If you want, I can provide: • The exact
lk webhook get
output fields to check, or • A command to list delivery attempts in the Cloud (if you want me to walk through checking delivery logs). Which would you like?
f
i don't see any of these command sin my livekit cli , what version are you on? (sentinum-base) akashmeesa@Mac backend % lk NAME: lk - CLI client to LiveKit USAGE: lk [global options] [command [command options]] VERSION: 2.4.10 DESCRIPTION: A suite of command line utilities allowing you to access LiveKit APIs services, interact with rooms in realtime, and perform load testing simulations. COMMANDS: app Initialize and manage applications agent, a Manage LiveKit Cloud Agents cloud Interact with LiveKit Cloud services project Add or remove projects and view existing project properties room Create or delete rooms and manage existing room properties token Create access tokens with granular capabilities dispatch Create, list, and delete agent dispatches egress Record or stream media from LiveKit to elsewhere ingress Import outside media sources into a LiveKit room sip Manage SIP Trunks, Dispatch Rules, and Participants perf Performance testing commands GLOBAL OPTIONS: --url URL URL to LiveKit instance (default: "http://localhost:7880") [$LIVEKIT_URL] --api-key KEY Your KEY [$LIVEKIT_API_KEY] --api-secret SECRET Your SECRET [$LIVEKIT_API_SECRET] --dev Use developer credentials for local LiveKit server (default: false) --project NAME NAME of a configured project --subdomain SUBDOMAIN SUBDOMAIN of a configured project --config TOML Config TOML to use in the working directory (default: "livekit.toml") --curl Print curl commands for API actions (default: false) --verbose (default: false) --help, -h show help --version, -v print the version (sentinum-base) akashmeesa@Mac backend % lk --version lk version 2.4.10 (sentinum-base) akashmeesa@Mac backend %
t
Thanks — since you’re on LiveKit Cloud and your
lk
CLI (v2.4.10) doesn’t show webhook commands, here’s a concise plan and actions to get your webhook delivering only room_finished and verify deliveries. 1) Confirm current Cloud webhook config • Open Project → Settings → Webhooks and confirm the URL exactly matches your API Gateway endpoint (including path/stage). • If the dashboard UI does not show event toggles, filtering via CLI isn’t available in your version. 2) Use Cloud delivery logs to see what LiveKit sent • In the same Webhooks page look for Delivery / Recent Attempts. That view shows request timestamps, response codes, and error details for each delivery. Check for entries to your API Gateway URL and any failures. 3) Verify API Gateway is forwarding requests exactly • Confirm the integration is Lambda Proxy (this forwards headers + body as-is). • Ensure Authorization header is forwarded. If using a custom mapping template, include: ◦ headers.Authorization -> event.headers.Authorization • Make sure the endpoint is public (no API Key/IAM restriction) so LiveKit Cloud can POST. 4) Quick tests to isolate where requests stop • From a public host (or curl from your laptop) send a signed LiveKit-style request to the webhook URL using your test script — if that reaches Lambda but LiveKit deliveries do not, problem is on LiveKit side or delivery logs will show attempts/failures. • If LiveKit delivery logs show 2xx but Lambda logs are empty, API Gateway might be returning success before invoking Lambda (check method integration and stage settings). 5) If dashboard logs show no attempts • LiveKit isn’t sending. Common causes: ◦ Webhook disabled or misconfigured URL in project settings ◦ Project plan/feature limitations (contact Cloud UI or check project settings) • If you see attempts with non-2xx responses, use the logged response body to debug (often Authorization missing or 4xx from API Gateway). 6) Next small checks I can provide commands for (pick one) • I can give a curl/python snippet that simulates LiveKit-signed webhook exactly (so you can verify public reachability). • I can list the exact CloudWatch filter queries to find API Gateway + Lambda logs for recent timestamps. • I can show the API Gateway setting to ensure Authorization header is passed. Summary: Your CLI v2.4.10 doesn’t include webhook event filtering. Use the Cloud Webhooks delivery logs to see what LiveKit is sending, confirm API Gateway is forwarding Authorization and body, and run a public signed test to compare. Tell me which of the three checks above you want the exact commands for (curl signed test / CloudWatch filter / API Gateway header check) and I’ll give step-by-step commands. Sources: Webhooks | LiveKit docs | server-sdk-js | livekit/livekit_composite | DeepWiki
f
actually while im looking at livekit cloud, if i'm using the livekit infrastructure but my agents are hosted in aws ecs am i self hosted?
t
Short answer: No — using LiveKit Cloud while your agents or other app components run in AWS ECS is not self-hosted. Clarification: • LiveKit Cloud means LiveKit runs as a managed service (the LiveKit server, webhooks, control plane, etc.) and you do not self-host the LiveKit server. • Hosting your own application logic (agents, workers, ECS tasks, Lambdas) in AWS is separate — those are your infrastructure, but LiveKit itself remains cloud-managed. When you are considered self-hosted: • You run the LiveKit server (the service binary/container) yourself (on your VMs, k8s, ECS, etc.) and configure webhooks via your server config (YAML) or local tooling. Then you control
event_filters
in that config. When you are on LiveKit Cloud: • Webhooks are managed by LiveKit Cloud. Dashboard or Cloud APIs determine delivery. You cannot change server config YAML for the LiveKit service because you’re not hosting it. If you want next steps, I can: • Walk you through verifying webhook delivery and API Gateway header forwarding for your Cloud webhook, or • Show how to safely filter in your Lambda if dashboard filtering isn't available for your project. Which would you like me to do?
f
if im just using the livekit server api and agents and hosting the infra on my ecs i use livekit for the load management and worker distribution, is there a way to interact with the webhooks this way?
t
Thanks — quick clarification and guidance based on your setup question. Short answer • If you run the LiveKit server yourself (on ECS, k8s, VMs) you are self-hosting and can configure webhooks/event_filters in your server config. • If you use LiveKit Cloud (LiveKit-managed server) but run your own agents/workers on ECS, you are NOT self-hosted. LiveKit Cloud controls webhook delivery; you receive events at your endpoint (ECS, Lambda, etc.) and must verify/filter them there unless the Cloud dashboard/Cloud API exposes event filtering for your project. How you can interact with webhooks in your hybrid setup • Receiving webhooks: Point LiveKit Cloud webhook URL to any public endpoint you host (ECS service, ALB, API Gateway → Lambda). LiveKit will POST signed webhook payloads; your service must verify signatures and parse the body. • Filtering: ◦ If you need filtering at the source (so LiveKit only sends specific events), that requires either dashboard support for event filters on your Cloud project or running your own LiveKit server (self-hosted) where you set
event_filters
in config.yaml. ◦ If dashboard filtering isn’t available for your project, filter in your code: verify the JWT, check
body["event"]
, and return quickly for non-matching events (this is what your Lambda already does). • Programmatic webhook management: ◦ LiveKit Cloud may expose REST endpoints / dashboard controls for webhook config. If your CLI/version lacks webhook commands, use the Cloud UI or Cloud API (if available for your plan) to create/update webhooks. ◦ When self-hosting, you edit server config YAML to add
webhook.urls
and
event_filters
. Practical recommendations • Keep your verification + early-return logic in the service that receives webhooks (ECS or Lambda). This is simplest and safe. • If you need to avoid LiveKit invoking your endpoint for non-room_finished (to save Lambda/ECS cost), check if your Cloud project’s webhook settings expose event filtering. If not, consider self-hosting the LiveKit server or using a lightweight proxy that forwards only room_finished to your main handler. • Ensure your public endpoint accepts LiveKit’s signed requests and that API Gateway/ALB forwards Authorization and body unchanged. If you want, I can: • Show the exact HTTP request shape LiveKit Cloud sends (headers, example body) so you can validate your receiver. • Provide a tiny proxy example that accepts all webhooks, verifies them, and forwards only room_finished to your Lambda. • Walk through how to check Cloud Webhooks delivery logs in the LiveKit dashboard and what to look for. Which of those would help you most right now?
f
so my livekit voice agent seems to be sending the hook, but now im seeing a hash mismatch 2025-12-19T042921.531Z INIT_START Runtime Version: python:3.11.v109 Runtime Version ARN: arnawslambdaus east 1runtime49f733259c7ce7e0deee75ff91c6afe35c7d58b04ed300f32701216263b4590c 2025-12-19T042922.798Z START RequestId: 38ef12f6-8f82-4369-af40-5d18abfda8cb Version: $LATEST 2025-12-19T042922.799Z [ERROR] 2025-12-19T042922.799Z 38ef12f6-8f82-4369-af40-5d18abfda8cb Hash Mismatch 2025-12-19T042922.801Z END RequestId: 38ef12f6-8f82-4369-af40-5d18abfda8cb 2025-12-19T042922.801Z REPORT RequestId: 38ef12f6-8f82-4369-af40-5d18abfda8cb Duration: 3.02 ms Billed Duration: 1267 ms Memory Size: 128 MB Max Memory Used: 93 MB Init Duration: 1263.41 ms 2025-12-19T042924.385Z START RequestId: 5ddea0ce-c97a-41e6-b550-f6a2bbba990e Version: $LATEST 2025-12-19T042924.386Z [ERROR] 2025-12-19T042924.386Z 5ddea0ce-c97a-41e6-b550-f6a2bbba990e Hash Mismatch 2025-12-19T042924.388Z END RequestId: 5ddea0ce-c97a-41e6-b550-f6a2bbba990e 2025-12-19T042924.388Z REPORT RequestId: 5ddea0ce-c97a-41e6-b550-f6a2bbba990e Duration: 2.06 ms Billed Duration: 3 ms Memory Size: 128 MB Max Memory Used: 93 MB 2025-12-19T043033.151Z START RequestId: 3cb81eec-8a49-4bca-a3dd-6eba47810662 Version: $LATEST 2025-12-19T043033.152Z [ERROR] 2025-12-19T043033.152Z 3cb81eec-8a49-4bca-a3dd-6eba47810662 Hash Mismatch 2025-12-19T043033.162Z END RequestId: 3cb81eec-8a49-4bca-a3dd-6eba47810662 2025-12-19T043033.162Z REPORT RequestId: 3cb81eec-8a49-4bca-a3dd-6eba47810662 Duration: 10.45 ms Billed Duration: 11 ms Memory Size: 128 MB Max Memory Used: 93 MB 2025-12-19T043033.195Z START RequestId: 1bf435e3-9114-464c-a065-c1116ebc53ea Version: $LATEST 2025-12-19T043033.200Z [ERROR] 2025-12-19T043033.200Z 1bf435e3-9114-464c-a065-c1116ebc53ea Hash Mismatch 2025-12-19T043033.202Z END RequestId: 1bf435e3-9114-464c-a065-c1116ebc53ea 2025-12-19T043033.202Z REPORT RequestId: 1bf435e3-9114-464c-a065-c1116ebc53ea Duration: 6.22 ms Billed Duration: 7 ms Memory Size: 128 MB Max Memory Used: 93 MB 2025-12-19T043033.245Z START RequestId: b786c6c4-5ba8-4462-86e1-812a93de7960 Version: $LATEST 2025-12-19T043033.246Z [ERROR] 2025-12-19T043033.246Z b786c6c4-5ba8-4462-86e1-812a93de7960 Hash Mismatch 2025-12-19T043033.247Z END RequestId: b786c6c4-5ba8-4462-86e1-812a93de7960 2025-12-19T043033.247Z REPORT RequestId: b786c6c4-5ba8-4462-86e1-812a93de7960 Duration: 1.44 ms Billed Duration: 2 ms Memory Size: 128 MB Max Memory Used: 93 MB 2025-12-19T043033.279Z START RequestId: 9a3d876d-b50a-4587-9b6b-bc75e697c73b Version: $LATEST 2025-12-19T043033.280Z [ERROR] 2025-12-19T043033.279Z 9a3d876d-b50a-4587-9b6b-bc75e697c73b Hash Mismatch 2025-12-19T043033.281Z END RequestId: 9a3d876d-b50a-4587-9b6b-bc75e697c73b 2025-12-19T043033.281Z REPORT RequestId: 9a3d876d-b50a-4587-9b6b-bc75e697c73b Duration: 1.59 ms Billed Duration: 2 ms Memory Size: 128 MB Max Memory Used: 93 MB 2025-12-19T043033.593Z START RequestId: 4d92c1d4-12b0-45e8-8bbd-42fc44b1dfeb Version: $LATEST 2025-12-19T043033.594Z [ERROR] 2025-12-19T043033.594Z 4d92c1d4-12b0-45e8-8bbd-42fc44b1dfeb Hash Mismatch 2025-12-19T043033.595Z END RequestId: 4d92c1d4-12b0-45e8-8bbd-42fc44b1dfeb 2025-12-19T043033.595Z REPORT RequestId: 4d92c1d4-12b0-45e8-8bbd-42fc44b1dfeb Duration: 1.46 ms Billed Duration: 2 ms Memory Size: 128 MB Max Memory Used: 93 MB 2025-12-19T043034.147Z START RequestId: 75abc3bc-befe-4a8b-b053-e9405d7b6905 Version: $LATEST 2025-12-19T043034.147Z [ERROR] 2025-12-19T043034.147Z 75abc3bc-befe-4a8b-b053-e9405d7b6905 Hash Mismatch 2025-12-19T043034.149Z END RequestId: 75abc3bc-befe-4a8b-b053-e9405d7b6905 2025-12-19T043034.149Z REPORT RequestId: 75abc3bc-befe-4a8b-b053-e9405d7b6905 Duration: 1.67 ms Billed Duration: 2 ms Memory Size: 128 MB Max Memory Used: 93 MB No newer events at this moment. Auto retry paused. Resume
t
Thanks — I can help diagnose the hash mismatch and show a corrected verification approach. I’ll first acknowledge what you reported and then give concise, actionable checks and a small code change to log values that will reveal the mismatch. Acknowledgement • You’re receiving LiveKit Cloud webhooks but your Lambda logs show “Hash Mismatch” during token verification. Most likely causes • The JWT’s sha256 claim differs from the SHA256 computed from the exact bytes your Lambda used (common causes below). ◦ API Gateway altered the request body (encoding, newline, or whitespace changes). ◦ Content-type or binary/pass-through handling changed the bytes (LiveKit sends
application/webhook+json
). ◦ A different LiveKit project key/secret is used to sign vs the one your Lambda verifies. ◦ JSON was re-serialized with different spacing/ordering before hashing on sender (LiveKit signs raw bytes it sends). Immediate checks (do these first) 1. Confirm API Gateway is using Lambda Proxy integration so headers/body are forwarded unchanged. 2. Ensure API Gateway does not modify or re-encode the body: ◦ In API Gateway settings, add
application/webhook+json
to Binary Media Types (if using non-proxy or if body arrives base64). ◦ If using HTTP API, ensure payload passthrough is enabled. 1. Confirm the LiveKit project API key/secret in Lambda exactly matches the project that sent the webhook. 2. In LiveKit Cloud Webhooks delivery logs, inspect the request body shown (if available) and the response code/time. Add diagnostic logs to reveal the mismatch • Add logging of: ◦ Authorization header (the token string — only during debug; remove later) ◦
claims.sha256
from the verified token ◦ The local computed hash of the raw bytes (hex) ◦ Whether event body was base64-encoded coming into Lambda (event["isBase64Encoded"]) Minimal snippet to add around verification (log only while debugging):
Copy code
# after computing raw_body_bytes and before comparing:
logger.debug(f"isBase64Encoded={event.get('isBase64Encoded')}")
logger.debug(f"local_hash={local_hash}")

# after token_verifier.verify(auth_header):
logger.debug(f"token_sha256={getattr(claims, 'sha256', None)}")
logger.debug(f"auth_header_preview={auth_header[:40]}...")  # remove after debug
This will show whether the token claim and local hash differ and whether the body arrived base64-encoded. Correct verification pattern to use (concise) • Use the raw bytes delivered to Lambda exactly as received: ◦ If
isBase64Encoded
is True → base64 decode event["body"]. ◦ Else → encode event["body"] to UTF-8 bytes (do not json.dumps or otherwise re-serialize). • Compute sha256 on those exact bytes and compare to
claims.sha256
. Example checklist to fix common issues • If local_hash differs by small amount (like whitespace/newline): ◦ Check whether API Gateway or any middleware normalized the body. Use binary passthrough for
application/webhook+json
. • If token_sha256 is missing or different: ◦ Confirm the Authorization token is the LiveKit-signed webhook token for this project. ◦ Confirm LiveKit is configured to sign webhooks for this webhook (dashboard shows “Signed with” — you already saw that). • If Authorization header is missing in Lambda: ◦ Ensure API Gateway forwards the Authorization header (Lambda Proxy does this automatically). • If LiveKit delivery logs show 4xx/5xx: ◦ Inspect logged response body — often API Gateway returns 4xx because of CORS, auth, or mapping errors. If you want, I can: • Provide an updated lambda_handler patch that adds the debug logs and preserves your secure checks (I can include it here). • Walk you through the exact API Gateway setting to ensure
application/webhook+json
is passed through as raw bytes. Which would you like me to give next — the small patched handler with debug logs, or the step-by-step API Gateway settings?