From 9ba1cb4b851eae5fb22865ede3b77895a6b493a8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 24 Jan 2026 20:09:17 +0000 Subject: [PATCH 1/7] Update webhooks page to clarify it's for pre-recorded audio Co-Authored-By: Lee Vaughn --- fern/docs.yml | 2 +- fern/pages/05-guides/webhooks.mdx | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/fern/docs.yml b/fern/docs.yml index ab5e2e39..6c633c5f 100644 --- a/fern/docs.yml +++ b/fern/docs.yml @@ -302,7 +302,7 @@ navigation: - page: Self-hosted streaming path: pages/05-guides/self-hosted-streaming.mdx hidden: true - - page: Webhooks + - page: Webhooks for pre-recorded audio path: pages/05-guides/webhooks.mdx - page: Evaluating STT models path: pages/08-concepts/evals.mdx diff --git a/fern/pages/05-guides/webhooks.mdx b/fern/pages/05-guides/webhooks.mdx index 75df9cfa..223ccdac 100644 --- a/fern/pages/05-guides/webhooks.mdx +++ b/fern/pages/05-guides/webhooks.mdx @@ -1,10 +1,14 @@ --- -title: "Webhooks" +title: "Webhooks for pre-recorded audio" hide-nav-links: true -description: "Get notified when a transcription is ready." +description: "Get notified when a pre-recorded audio transcription is ready." --- -Webhooks are custom HTTP callbacks that you can define to get notified when your transcripts are ready. +Webhooks are custom HTTP callbacks that you can define to get notified when your pre-recorded audio transcripts are ready. + + +This guide covers webhooks for [pre-recorded audio transcription](/docs/speech-to-text/pre-recorded-audio). Webhooks are not available for [streaming audio transcription](/docs/speech-to-text/universal-streaming). + To use webhooks, you need to set up your own webhook receiver to handle webhook deliveries. From a5468170f39884e2c0d0ad869fa79515bd9a83af Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 24 Jan 2026 20:11:48 +0000 Subject: [PATCH 2/7] Apply formatting to webhooks.mdx Co-Authored-By: Lee Vaughn --- fern/pages/05-guides/webhooks.mdx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/fern/pages/05-guides/webhooks.mdx b/fern/pages/05-guides/webhooks.mdx index 223ccdac..46692018 100644 --- a/fern/pages/05-guides/webhooks.mdx +++ b/fern/pages/05-guides/webhooks.mdx @@ -7,7 +7,10 @@ description: "Get notified when a pre-recorded audio transcription is ready." Webhooks are custom HTTP callbacks that you can define to get notified when your pre-recorded audio transcripts are ready. -This guide covers webhooks for [pre-recorded audio transcription](/docs/speech-to-text/pre-recorded-audio). Webhooks are not available for [streaming audio transcription](/docs/speech-to-text/universal-streaming). + This guide covers webhooks for [pre-recorded audio + transcription](/docs/speech-to-text/pre-recorded-audio). Webhooks are not + available for [streaming audio + transcription](/docs/speech-to-text/universal-streaming). To use webhooks, you need to set up your own webhook receiver to handle webhook deliveries. From d1e0d89dafef70deb56b4e7e4d4f3ac76b6d3a08 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 28 Jan 2026 00:51:06 +0000 Subject: [PATCH 3/7] Add webhooks for streaming speech-to-text page Co-Authored-By: Lee Vaughn --- fern/docs.yml | 2 + fern/pages/05-guides/webhooks-streaming.mdx | 339 ++++++++++++++++++++ 2 files changed, 341 insertions(+) create mode 100644 fern/pages/05-guides/webhooks-streaming.mdx diff --git a/fern/docs.yml b/fern/docs.yml index 6c633c5f..3b6e71a0 100644 --- a/fern/docs.yml +++ b/fern/docs.yml @@ -304,6 +304,8 @@ navigation: hidden: true - page: Webhooks for pre-recorded audio path: pages/05-guides/webhooks.mdx + - page: Webhooks for streaming speech-to-text + path: pages/05-guides/webhooks-streaming.mdx - page: Evaluating STT models path: pages/08-concepts/evals.mdx - page: Account Management diff --git a/fern/pages/05-guides/webhooks-streaming.mdx b/fern/pages/05-guides/webhooks-streaming.mdx new file mode 100644 index 00000000..7fc4fd50 --- /dev/null +++ b/fern/pages/05-guides/webhooks-streaming.mdx @@ -0,0 +1,339 @@ +--- +title: "Webhooks for streaming speech-to-text" +hide-nav-links: true +description: "Get notified when a streaming transcription session ends." +--- + +Webhooks allow you to receive the complete transcript via HTTP callback when a streaming session ends. This is in addition to the normal WebSocket responses you receive during the session. + + + This guide covers webhooks for [streaming audio + transcription](/docs/speech-to-text/universal-streaming). For webhooks with + pre-recorded audio, see [Webhooks for pre-recorded + audio](/docs/deployment/webhooks). + + +## Configure webhooks for a streaming session + +To use webhooks with streaming speech-to-text, add the following parameters to your WebSocket connection URL: + +| Parameter | Required | Description | +| --------------------------- | -------- | ------------------------------------------------------------------------- | +| `webhook_url` | Yes | The URL to send the transcript to when the session ends. | +| `webhook_auth_header_name` | No | The name of the authentication header to include in the webhook request. | +| `webhook_auth_header_value` | No | The value of the authentication header to include in the webhook request. | + + + Create a test webhook endpoint with [webhook.site](https://webhook.site) to + test your webhook integration. + + +### Example WebSocket URL with webhook parameters + +Add the webhook parameters as query parameters to the WebSocket URL: + +``` +wss://api.assemblyai.com/v2/realtime/ws?sample_rate=16000&webhook_url=https://example.com/webhook +``` + +To include authentication: + +``` +wss://api.assemblyai.com/v2/realtime/ws?sample_rate=16000&webhook_url=https://example.com/webhook&webhook_auth_header_name=X-My-Webhook-Secret&webhook_auth_header_value=secret-value +``` + + + + +```python +import assemblyai as aai + +aai.settings.api_key = "" + +def on_data(transcript: aai.RealtimeTranscript): + if not transcript.text: + return + + if isinstance(transcript, aai.RealtimeFinalTranscript): + print(transcript.text, end="\r\n") + else: + print(transcript.text, end="\r") + +def on_error(error: aai.RealtimeError): + print("An error occurred:", error) + +def on_close(): + print("Session closed") + +# Configure the transcriber with webhook parameters +transcriber = aai.RealtimeTranscriber( + on_data=on_data, + on_error=on_error, + on_close=on_close, + sample_rate=16_000, + extra_session_information={ + "webhook_url": "https://example.com/webhook", + "webhook_auth_header_name": "X-My-Webhook-Secret", # Optional + "webhook_auth_header_value": "secret-value" # Optional + } +) + +transcriber.connect() + +# Stream your audio data here +# transcriber.stream(audio_data) + +transcriber.close() +``` + + + + +```javascript +import { RealtimeTranscriber } from "assemblyai"; + +const transcriber = new RealtimeTranscriber({ + apiKey: "", + sampleRate: 16_000, + // Add webhook parameters + webhookUrl: "https://example.com/webhook", + webhookAuthHeaderName: "X-My-Webhook-Secret", // Optional + webhookAuthHeaderValue: "secret-value", // Optional +}); + +transcriber.on("transcript", (transcript) => { + if (!transcript.text) { + return; + } + + if (transcript.message_type === "FinalTranscript") { + console.log(transcript.text); + } +}); + +transcriber.on("error", (error) => { + console.error("Error:", error); +}); + +transcriber.on("close", (code, reason) => { + console.log("Session closed:", code, reason); +}); + +await transcriber.connect(); + +// Stream your audio data here +// transcriber.sendAudio(audioData); + +await transcriber.close(); +``` + + + + +```python +import websocket +import json +import base64 +import urllib.parse + +API_KEY = "" +SAMPLE_RATE = 16000 +WEBHOOK_URL = "https://example.com/webhook" + +# Build the WebSocket URL with webhook parameters +params = { + "sample_rate": SAMPLE_RATE, + "webhook_url": WEBHOOK_URL, + # Optional authentication + "webhook_auth_header_name": "X-My-Webhook-Secret", + "webhook_auth_header_value": "secret-value" +} + +url = f"wss://api.assemblyai.com/v2/realtime/ws?{urllib.parse.urlencode(params)}" + +def on_message(ws, message): + data = json.loads(message) + if data.get("message_type") == "FinalTranscript": + print(data.get("text", "")) + +def on_error(ws, error): + print(f"Error: {error}") + +def on_close(ws, close_status_code, close_msg): + print("Connection closed") + +def on_open(ws): + print("Connection opened") + +ws = websocket.WebSocketApp( + url, + header={"Authorization": API_KEY}, + on_open=on_open, + on_message=on_message, + on_error=on_error, + on_close=on_close +) + +ws.run_forever() +``` + + + + +```javascript +const WebSocket = require("ws"); + +const API_KEY = ""; +const SAMPLE_RATE = 16000; +const WEBHOOK_URL = "https://example.com/webhook"; + +// Build the WebSocket URL with webhook parameters +const params = new URLSearchParams({ + sample_rate: SAMPLE_RATE, + webhook_url: WEBHOOK_URL, + // Optional authentication + webhook_auth_header_name: "X-My-Webhook-Secret", + webhook_auth_header_value: "secret-value", +}); + +const url = `wss://api.assemblyai.com/v2/realtime/ws?${params.toString()}`; + +const ws = new WebSocket(url, { + headers: { + Authorization: API_KEY, + }, +}); + +ws.on("open", () => { + console.log("Connection opened"); +}); + +ws.on("message", (data) => { + const message = JSON.parse(data); + if (message.message_type === "FinalTranscript") { + console.log(message.text); + } +}); + +ws.on("error", (error) => { + console.error("Error:", error); +}); + +ws.on("close", () => { + console.log("Connection closed"); +}); +``` + + + + +## Handle webhook deliveries + +When the streaming session ends, AssemblyAI sends a `POST` HTTP request to the URL you specified. The webhook contains the complete transcript from the session. + + + +AssemblyAI sends all webhook deliveries from fixed IP addresses: + +| Region | IP Address | +| ------ | -------------- | +| US | `44.238.19.20` | +| EU | `54.220.25.36` | + + + +### Delivery payload + +The webhook delivery payload contains the complete transcript from the streaming session as a JSON object. The payload includes all the final transcript segments combined. + +### Example webhook receiver + + + + +```python +from flask import Flask, request, jsonify + +app = Flask(__name__) + +@app.route("/webhook", methods=["POST"]) +def webhook(): + # Verify the authentication header if you configured one + auth_header = request.headers.get("X-My-Webhook-Secret") + if auth_header != "secret-value": + return jsonify({"error": "Unauthorized"}), 401 + + # Process the transcript data + data = request.json + print("Received transcript:", data) + + # Return a success response + return jsonify({"status": "received"}), 200 + +if __name__ == "__main__": + app.run(port=5000) +``` + + + + +```javascript +const express = require("express"); +const app = express(); + +app.use(express.json()); + +app.post("/webhook", (req, res) => { + // Verify the authentication header if you configured one + const authHeader = req.headers["x-my-webhook-secret"]; + if (authHeader !== "secret-value") { + return res.status(401).json({ error: "Unauthorized" }); + } + + // Process the transcript data + const data = req.body; + console.log("Received transcript:", data); + + // Return a success response + res.status(200).json({ status: "received" }); +}); + +app.listen(5000, () => { + console.log("Webhook server listening on port 5000"); +}); +``` + + + + +## Authenticate webhook deliveries + +To secure your webhook endpoint, you can include custom authentication headers in the webhook request. When configuring your streaming session, provide the `webhook_auth_header_name` and `webhook_auth_header_value` parameters. + +AssemblyAI will include this header in the webhook request, allowing you to verify that the request came from AssemblyAI. + +``` +webhook_auth_header_name=X-My-Webhook-Secret&webhook_auth_header_value=secret-value +``` + +In your webhook receiver, verify the header value matches what you configured: + +```python +auth_header = request.headers.get("X-My-Webhook-Secret") +if auth_header != "secret-value": + return "Unauthorized", 401 +``` + +## Best practices + +When implementing webhooks for streaming speech-to-text, consider the following best practices: + +1. **Always verify authentication**: If you configure an authentication header, always verify it in your webhook receiver to ensure requests are from AssemblyAI. + +2. **Respond quickly**: Return a response from your webhook endpoint as quickly as possible. If you need to perform time-consuming processing, do it asynchronously after returning the response. + +3. **Handle failures gracefully**: Your webhook endpoint should handle errors gracefully and return appropriate HTTP status codes. + +4. **Use HTTPS**: Always use HTTPS for your webhook URL to ensure the transcript data is encrypted in transit. + +5. **Log webhook deliveries**: Keep logs of webhook deliveries for debugging and auditing purposes. From 2ef2b257b989e5dd10b0298b074c2cf2e19403f0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 28 Jan 2026 01:07:31 +0000 Subject: [PATCH 4/7] Update streaming webhooks page with reference code examples Co-Authored-By: Lee Vaughn --- fern/pages/05-guides/webhooks-streaming.mdx | 363 ++++++++++++-------- 1 file changed, 221 insertions(+), 142 deletions(-) diff --git a/fern/pages/05-guides/webhooks-streaming.mdx b/fern/pages/05-guides/webhooks-streaming.mdx index 7fc4fd50..9bff7898 100644 --- a/fern/pages/05-guides/webhooks-streaming.mdx +++ b/fern/pages/05-guides/webhooks-streaming.mdx @@ -33,148 +33,195 @@ To use webhooks with streaming speech-to-text, add the following parameters to y Add the webhook parameters as query parameters to the WebSocket URL: ``` -wss://api.assemblyai.com/v2/realtime/ws?sample_rate=16000&webhook_url=https://example.com/webhook +wss://streaming.assemblyai.com/v3/ws?sample_rate=16000&webhook_url=https://example.com/webhook ``` To include authentication: ``` -wss://api.assemblyai.com/v2/realtime/ws?sample_rate=16000&webhook_url=https://example.com/webhook&webhook_auth_header_name=X-My-Webhook-Secret&webhook_auth_header_value=secret-value +wss://streaming.assemblyai.com/v3/ws?sample_rate=16000&webhook_url=https://example.com/webhook&webhook_auth_header_name=X-Webhook-Secret&webhook_auth_header_value=secret-value ``` - + ```python -import assemblyai as aai - -aai.settings.api_key = "" - -def on_data(transcript: aai.RealtimeTranscript): - if not transcript.text: - return - - if isinstance(transcript, aai.RealtimeFinalTranscript): - print(transcript.text, end="\r\n") - else: - print(transcript.text, end="\r") - -def on_error(error: aai.RealtimeError): - print("An error occurred:", error) - -def on_close(): - print("Session closed") - -# Configure the transcriber with webhook parameters -transcriber = aai.RealtimeTranscriber( - on_data=on_data, - on_error=on_error, - on_close=on_close, - sample_rate=16_000, - extra_session_information={ - "webhook_url": "https://example.com/webhook", - "webhook_auth_header_name": "X-My-Webhook-Secret", # Optional - "webhook_auth_header_value": "secret-value" # Optional - } -) - -transcriber.connect() - -# Stream your audio data here -# transcriber.stream(audio_data) - -transcriber.close() -``` - - - - -```javascript -import { RealtimeTranscriber } from "assemblyai"; - -const transcriber = new RealtimeTranscriber({ - apiKey: "", - sampleRate: 16_000, - // Add webhook parameters - webhookUrl: "https://example.com/webhook", - webhookAuthHeaderName: "X-My-Webhook-Secret", // Optional - webhookAuthHeaderValue: "secret-value", // Optional -}); - -transcriber.on("transcript", (transcript) => { - if (!transcript.text) { - return; - } +import pyaudio +import websocket +import json +import threading +import time +from urllib.parse import urlencode +from datetime import datetime + +# --- Configuration --- +YOUR_API_KEY = "" + +CONNECTION_PARAMS = { + "sample_rate": 16000, + "format_turns": True, + # Webhook parameters + "webhook_url": "https://example.com/webhook", + "webhook_auth_header_name": "X-Webhook-Secret", # Optional + "webhook_auth_header_value": "secret-value", # Optional +} +API_ENDPOINT_BASE_URL = "wss://streaming.assemblyai.com/v3/ws" +API_ENDPOINT = f"{API_ENDPOINT_BASE_URL}?{urlencode(CONNECTION_PARAMS)}" - if (transcript.message_type === "FinalTranscript") { - console.log(transcript.text); - } -}); +# Audio Configuration +FRAMES_PER_BUFFER = 800 # 50ms of audio (0.05s * 16000Hz) +SAMPLE_RATE = CONNECTION_PARAMS["sample_rate"] +CHANNELS = 1 +FORMAT = pyaudio.paInt16 -transcriber.on("error", (error) => { - console.error("Error:", error); -}); +# Global variables +audio = None +stream = None +ws_app = None +audio_thread = None +stop_event = threading.Event() -transcriber.on("close", (code, reason) => { - console.log("Session closed:", code, reason); -}); -await transcriber.connect(); +def on_open(ws): + """Called when the WebSocket connection is established.""" + print("WebSocket connection opened.") + print(f"Connected to: {API_ENDPOINT}") + + def stream_audio(): + global stream + print("Starting audio streaming...") + while not stop_event.is_set(): + try: + audio_data = stream.read(FRAMES_PER_BUFFER, exception_on_overflow=False) + ws.send(audio_data, websocket.ABNF.OPCODE_BINARY) + except Exception as e: + print(f"Error streaming audio: {e}") + break + print("Audio streaming stopped.") + + global audio_thread + audio_thread = threading.Thread(target=stream_audio) + audio_thread.daemon = True + audio_thread.start() -// Stream your audio data here -// transcriber.sendAudio(audioData); -await transcriber.close(); -``` +def on_message(ws, message): + """Called when a message is received from the WebSocket.""" + try: + data = json.loads(message) + msg_type = data.get("type") + + if msg_type == "Begin": + session_id = data.get("id") + expires_at = data.get("expires_at") + print(f"\nSession began: ID={session_id}, ExpiresAt={datetime.fromtimestamp(expires_at)}") + elif msg_type == "Turn": + transcript = data.get("transcript", "") + formatted = data.get("turn_is_formatted", False) + if formatted: + print("\r" + " " * 80 + "\r", end="") + print(transcript) + else: + print(f"\r{transcript}", end="") + elif msg_type == "Termination": + audio_duration = data.get("audio_duration_seconds", 0) + session_duration = data.get("session_duration_seconds", 0) + print(f"\nSession Terminated: Audio Duration={audio_duration}s, Session Duration={session_duration}s") + except json.JSONDecodeError as e: + print(f"Error decoding message: {e}") + except Exception as e: + print(f"Error handling message: {e}") - - -```python -import websocket -import json -import base64 -import urllib.parse - -API_KEY = "" -SAMPLE_RATE = 16000 -WEBHOOK_URL = "https://example.com/webhook" - -# Build the WebSocket URL with webhook parameters -params = { - "sample_rate": SAMPLE_RATE, - "webhook_url": WEBHOOK_URL, - # Optional authentication - "webhook_auth_header_name": "X-My-Webhook-Secret", - "webhook_auth_header_value": "secret-value" -} +def on_error(ws, error): + """Called when a WebSocket error occurs.""" + print(f"\nWebSocket Error: {error}") + stop_event.set() -url = f"wss://api.assemblyai.com/v2/realtime/ws?{urllib.parse.urlencode(params)}" -def on_message(ws, message): - data = json.loads(message) - if data.get("message_type") == "FinalTranscript": - print(data.get("text", "")) +def on_close(ws, close_status_code, close_msg): + """Called when the WebSocket connection is closed.""" + print(f"\nWebSocket Disconnected: Status={close_status_code}, Msg={close_msg}") + global stream, audio + stop_event.set() + + if stream: + if stream.is_active(): + stream.stop_stream() + stream.close() + stream = None + if audio: + audio.terminate() + audio = None + if audio_thread and audio_thread.is_alive(): + audio_thread.join(timeout=1.0) + + +def run(): + global audio, stream, ws_app + + audio = pyaudio.PyAudio() + + try: + stream = audio.open( + input=True, + frames_per_buffer=FRAMES_PER_BUFFER, + channels=CHANNELS, + format=FORMAT, + rate=SAMPLE_RATE, + ) + print("Microphone stream opened successfully.") + print("Speak into your microphone. Press Ctrl+C to stop.") + except Exception as e: + print(f"Error opening microphone stream: {e}") + if audio: + audio.terminate() + return -def on_error(ws, error): - print(f"Error: {error}") + ws_app = websocket.WebSocketApp( + API_ENDPOINT, + header={"Authorization": YOUR_API_KEY}, + on_open=on_open, + on_message=on_message, + on_error=on_error, + on_close=on_close, + ) + + ws_thread = threading.Thread(target=ws_app.run_forever) + ws_thread.daemon = True + ws_thread.start() + + try: + while ws_thread.is_alive(): + time.sleep(0.1) + except KeyboardInterrupt: + print("\nCtrl+C received. Stopping...") + stop_event.set() + + if ws_app and ws_app.sock and ws_app.sock.connected: + try: + terminate_message = {"type": "Terminate"} + ws_app.send(json.dumps(terminate_message)) + time.sleep(2) + except Exception as e: + print(f"Error sending termination message: {e}") + + if ws_app: + ws_app.close() + ws_thread.join(timeout=2.0) + + finally: + if stream and stream.is_active(): + stream.stop_stream() + if stream: + stream.close() + if audio: + audio.terminate() + print("Cleanup complete.") -def on_close(ws, close_status_code, close_msg): - print("Connection closed") -def on_open(ws): - print("Connection opened") - -ws = websocket.WebSocketApp( - url, - header={"Authorization": API_KEY}, - on_open=on_open, - on_message=on_message, - on_error=on_error, - on_close=on_close -) - -ws.run_forever() +if __name__ == "__main__": + run() ``` @@ -184,44 +231,76 @@ ws.run_forever() const WebSocket = require("ws"); const API_KEY = ""; -const SAMPLE_RATE = 16000; -const WEBHOOK_URL = "https://example.com/webhook"; - -// Build the WebSocket URL with webhook parameters -const params = new URLSearchParams({ - sample_rate: SAMPLE_RATE, - webhook_url: WEBHOOK_URL, - // Optional authentication - webhook_auth_header_name: "X-My-Webhook-Secret", - webhook_auth_header_value: "secret-value", + +const connectionParams = new URLSearchParams({ + sample_rate: 16000, + format_turns: true, + // Webhook parameters + webhook_url: "https://example.com/webhook", + webhook_auth_header_name: "X-Webhook-Secret", // Optional + webhook_auth_header_value: "secret-value", // Optional }); -const url = `wss://api.assemblyai.com/v2/realtime/ws?${params.toString()}`; +const API_ENDPOINT = `wss://streaming.assemblyai.com/v3/ws?${connectionParams.toString()}`; -const ws = new WebSocket(url, { +const ws = new WebSocket(API_ENDPOINT, { headers: { Authorization: API_KEY, }, }); ws.on("open", () => { - console.log("Connection opened"); + console.log("WebSocket connection opened."); + console.log(`Connected to: ${API_ENDPOINT}`); + + // Start streaming audio data here + // For example, using a microphone input library }); ws.on("message", (data) => { - const message = JSON.parse(data); - if (message.message_type === "FinalTranscript") { - console.log(message.text); + try { + const message = JSON.parse(data); + const msgType = message.type; + + if (msgType === "Begin") { + const sessionId = message.id; + const expiresAt = new Date(message.expires_at * 1000); + console.log(`\nSession began: ID=${sessionId}, ExpiresAt=${expiresAt}`); + } else if (msgType === "Turn") { + const transcript = message.transcript || ""; + const formatted = message.turn_is_formatted || false; + if (formatted) { + process.stdout.write("\r" + " ".repeat(80) + "\r"); + console.log(transcript); + } else { + process.stdout.write(`\r${transcript}`); + } + } else if (msgType === "Termination") { + const audioDuration = message.audio_duration_seconds || 0; + const sessionDuration = message.session_duration_seconds || 0; + console.log( + `\nSession Terminated: Audio Duration=${audioDuration}s, Session Duration=${sessionDuration}s` + ); + } + } catch (e) { + console.error("Error handling message:", e); } }); ws.on("error", (error) => { - console.error("Error:", error); + console.error("WebSocket Error:", error); }); -ws.on("close", () => { - console.log("Connection closed"); +ws.on("close", (code, reason) => { + console.log(`WebSocket Disconnected: Status=${code}, Msg=${reason}`); }); + +// To gracefully close the session, send a Terminate message +function terminateSession() { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "Terminate" })); + } +} ``` @@ -259,7 +338,7 @@ app = Flask(__name__) @app.route("/webhook", methods=["POST"]) def webhook(): # Verify the authentication header if you configured one - auth_header = request.headers.get("X-My-Webhook-Secret") + auth_header = request.headers.get("X-Webhook-Secret") if auth_header != "secret-value": return jsonify({"error": "Unauthorized"}), 401 @@ -285,7 +364,7 @@ app.use(express.json()); app.post("/webhook", (req, res) => { // Verify the authentication header if you configured one - const authHeader = req.headers["x-my-webhook-secret"]; + const authHeader = req.headers["x-webhook-secret"]; if (authHeader !== "secret-value") { return res.status(401).json({ error: "Unauthorized" }); } @@ -313,13 +392,13 @@ To secure your webhook endpoint, you can include custom authentication headers i AssemblyAI will include this header in the webhook request, allowing you to verify that the request came from AssemblyAI. ``` -webhook_auth_header_name=X-My-Webhook-Secret&webhook_auth_header_value=secret-value +webhook_auth_header_name=X-Webhook-Secret&webhook_auth_header_value=secret-value ``` In your webhook receiver, verify the header value matches what you configured: ```python -auth_header = request.headers.get("X-My-Webhook-Secret") +auth_header = request.headers.get("X-Webhook-Secret") if auth_header != "secret-value": return "Unauthorized", 401 ``` From 726782196be8f23b4d0ec89e48f7add258d28b06 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 28 Jan 2026 01:10:47 +0000 Subject: [PATCH 5/7] Update pre-recorded webhooks note to link to streaming webhooks page Co-Authored-By: Lee Vaughn --- fern/pages/05-guides/webhooks.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/fern/pages/05-guides/webhooks.mdx b/fern/pages/05-guides/webhooks.mdx index 46692018..358858b2 100644 --- a/fern/pages/05-guides/webhooks.mdx +++ b/fern/pages/05-guides/webhooks.mdx @@ -8,9 +8,9 @@ Webhooks are custom HTTP callbacks that you can define to get notified when your This guide covers webhooks for [pre-recorded audio - transcription](/docs/speech-to-text/pre-recorded-audio). Webhooks are not - available for [streaming audio - transcription](/docs/speech-to-text/universal-streaming). + transcription](/docs/speech-to-text/pre-recorded-audio). For webhooks with + streaming audio, see [Webhooks for streaming + speech-to-text](/docs/deployment/webhooks-streaming). To use webhooks, you need to set up your own webhook receiver to handle webhook deliveries. From 270e098cde55a83c7123ba162047470e794c26f4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 28 Jan 2026 01:28:42 +0000 Subject: [PATCH 6/7] Add webhook payload example and remove receiver section Co-Authored-By: Lee Vaughn --- fern/pages/05-guides/webhooks-streaming.mdx | 103 +++++++++----------- 1 file changed, 44 insertions(+), 59 deletions(-) diff --git a/fern/pages/05-guides/webhooks-streaming.mdx b/fern/pages/05-guides/webhooks-streaming.mdx index 9bff7898..9611503d 100644 --- a/fern/pages/05-guides/webhooks-streaming.mdx +++ b/fern/pages/05-guides/webhooks-streaming.mdx @@ -323,67 +323,52 @@ AssemblyAI sends all webhook deliveries from fixed IP addresses: ### Delivery payload -The webhook delivery payload contains the complete transcript from the streaming session as a JSON object. The payload includes all the final transcript segments combined. - -### Example webhook receiver - - - - -```python -from flask import Flask, request, jsonify - -app = Flask(__name__) - -@app.route("/webhook", methods=["POST"]) -def webhook(): - # Verify the authentication header if you configured one - auth_header = request.headers.get("X-Webhook-Secret") - if auth_header != "secret-value": - return jsonify({"error": "Unauthorized"}), 401 - - # Process the transcript data - data = request.json - print("Received transcript:", data) - - # Return a success response - return jsonify({"status": "received"}), 200 - -if __name__ == "__main__": - app.run(port=5000) -``` - - - - -```javascript -const express = require("express"); -const app = express(); - -app.use(express.json()); - -app.post("/webhook", (req, res) => { - // Verify the authentication header if you configured one - const authHeader = req.headers["x-webhook-secret"]; - if (authHeader !== "secret-value") { - return res.status(401).json({ error: "Unauthorized" }); - } - - // Process the transcript data - const data = req.body; - console.log("Received transcript:", data); - - // Return a success response - res.status(200).json({ status: "received" }); -}); - -app.listen(5000, () => { - console.log("Webhook server listening on port 5000"); -}); +The webhook delivery payload contains the complete transcript from the streaming session as a JSON object. The payload includes the session ID and an array of messages containing all the transcript turns. + +```json +{ + "session_id": "273e79fd-99e9-4e1d-91da-90f56a132d01", + "messages": [ + { + "turn_order": 0, + "turn_is_formatted": true, + "end_of_turn": true, + "transcript": "Smoke from hundreds of wildfires in Canada is triggering air quality alerts throughout the US Skylines from Maine to Maryland to Minnesota are gray and smoggy, and in some places the air.", + "end_of_turn_confidence": 0.5005, + "words": [ + { + "start": 4880, + "end": 5040, + "text": "Smoke", + "confidence": 0.76054, + "word_is_final": true + }, + { + "start": 5280, + "end": 5360, + "text": "from", + "confidence": 0.761065, + "word_is_final": true + } + ], + "utterance": "", + "type": "Turn" + } + ] +} ``` - - +| Key | Type | Description | +| ----------------------------------- | ------- | -------------------------------------------------------------- | +| `session_id` | string | The unique identifier for the streaming session. | +| `messages` | array | An array of transcript turn objects from the session. | +| `messages[].turn_order` | integer | The order of the turn in the session (0-indexed). | +| `messages[].turn_is_formatted` | boolean | Whether the transcript has been formatted. | +| `messages[].end_of_turn` | boolean | Whether this message represents the end of a turn. | +| `messages[].transcript` | string | The transcribed text for this turn. | +| `messages[].end_of_turn_confidence` | number | Confidence score for the end of turn detection. | +| `messages[].words` | array | Word-level details including timestamps and confidence scores. | +| `messages[].type` | string | The message type, typically "Turn". | ## Authenticate webhook deliveries From 1944bd7648bdf353fd60224085997717cdf51f41 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 28 Jan 2026 01:45:51 +0000 Subject: [PATCH 7/7] Add SDK examples, webhook retry behavior, and remove C#/Ruby/PHP examples Co-Authored-By: Lee Vaughn --- fern/pages/05-guides/webhooks-streaming.mdx | 138 +++++++++ fern/pages/05-guides/webhooks.mdx | 308 +------------------- 2 files changed, 144 insertions(+), 302 deletions(-) diff --git a/fern/pages/05-guides/webhooks-streaming.mdx b/fern/pages/05-guides/webhooks-streaming.mdx index 9611503d..2c028cbc 100644 --- a/fern/pages/05-guides/webhooks-streaming.mdx +++ b/fern/pages/05-guides/webhooks-streaming.mdx @@ -43,6 +43,70 @@ wss://streaming.assemblyai.com/v3/ws?sample_rate=16000&webhook_url=https://examp ``` + + +```python +import assemblyai as aai +from assemblyai.streaming.v3 import ( + BeginEvent, + StreamingClient, + StreamingClientOptions, + StreamingError, + StreamingEvents, + StreamingParameters, + TerminationEvent, + TurnEvent, +) +from typing import Type + +api_key = "" + +def on_begin(self: Type[StreamingClient], event: BeginEvent): + print(f"Session started: {event.id}") + +def on_turn(self: Type[StreamingClient], event: TurnEvent): + print(f"{event.transcript} ({event.end_of_turn})") + +def on_terminated(self: Type[StreamingClient], event: TerminationEvent): + print(f"Session terminated: {event.audio_duration_seconds} seconds of audio processed") + +def on_error(self: Type[StreamingClient], error: StreamingError): + print(f"Error occurred: {error}") + +def main(): + client = StreamingClient( + StreamingClientOptions( + api_key=api_key, + api_host="streaming.assemblyai.com", + ) + ) + + client.on(StreamingEvents.Begin, on_begin) + client.on(StreamingEvents.Turn, on_turn) + client.on(StreamingEvents.Termination, on_terminated) + client.on(StreamingEvents.Error, on_error) + + client.connect( + StreamingParameters( + sample_rate=16000, + format_turns=True, + # Webhook parameters + webhook_url="https://example.com/webhook", + webhook_auth_header_name="X-Webhook-Secret", # Optional + webhook_auth_header_value="secret-value", # Optional + ) + ) + + try: + client.stream(aai.extras.MicrophoneStream(sample_rate=16000)) + finally: + client.disconnect(terminate=True) + +if __name__ == "__main__": + main() +``` + + ```python @@ -224,6 +288,78 @@ if __name__ == "__main__": run() ``` + + + +```javascript +import { Readable } from "stream"; +import { AssemblyAI } from "assemblyai"; +import recorder from "node-record-lpcm16"; + +const run = async () => { + const client = new AssemblyAI({ + apiKey: "", + }); + + const transcriber = client.streaming.transcriber({ + sampleRate: 16_000, + formatTurns: true, + // Webhook parameters + webhookUrl: "https://example.com/webhook", + webhookAuthHeaderName: "X-Webhook-Secret", // Optional + webhookAuthHeaderValue: "secret-value", // Optional + }); + + transcriber.on("open", ({ id }) => { + console.log(`Session opened with ID: ${id}`); + }); + + transcriber.on("error", (error) => { + console.error("Error:", error); + }); + + transcriber.on("close", (code, reason) => + console.log("Session closed:", code, reason) + ); + + transcriber.on("turn", (turn) => { + if (!turn.transcript) { + return; + } + console.log("Turn:", turn.transcript); + }); + + try { + console.log("Connecting to streaming transcript service"); + await transcriber.connect(); + + console.log("Starting recording"); + const recording = recorder.record({ + channels: 1, + sampleRate: 16_000, + audioType: "wav", + }); + + Readable.toWeb(recording.stream()).pipeTo(transcriber.stream()); + + process.on("SIGINT", async function () { + console.log(); + console.log("Stopping recording"); + recording.stop(); + + console.log("Closing streaming transcript connection"); + await transcriber.close(); + + process.exit(); + }); + } catch (error) { + console.error(error); + } +}; + +run(); +``` + @@ -310,6 +446,8 @@ function terminateSession() { When the streaming session ends, AssemblyAI sends a `POST` HTTP request to the URL you specified. The webhook contains the complete transcript from the session. +Your webhook endpoint must return a 2xx HTTP status code within 10 seconds to indicate successful receipt. If a 2xx status is not received within 10 seconds, AssemblyAI will retry the webhook call up to a total of 10 attempts. If at any point your endpoint returns a 4xx status code, the webhook call is considered failed and will not be retried. + AssemblyAI sends all webhook deliveries from fixed IP addresses: diff --git a/fern/pages/05-guides/webhooks.mdx b/fern/pages/05-guides/webhooks.mdx index 358858b2..065779a5 100644 --- a/fern/pages/05-guides/webhooks.mdx +++ b/fern/pages/05-guides/webhooks.mdx @@ -133,195 +133,6 @@ const url = `${baseUrl}/v2/transcript`; const response = await axios.post(url, data, { headers: headers }); ``` - - - -To create a webhook, set the `webhook_url` parameter when you create a new transcription. The URL must be accessible from AssemblyAI's servers. - -```csharp -using System; -using System.IO; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading.Tasks; - -public class Transcript -{ -public string Id { get; set; } -public string Status { get; set; } -public string Text { get; set; } -public string Error { get; set; } -} - -async Task UploadFileAsync(string filePath, HttpClient httpClient) -{ -using (var fileStream = File.OpenRead(filePath)) -using (var fileContent = new StreamContent(fileStream)) -{ -fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); - - using (var response = await httpClient.PostAsync("https://api.assemblyai.com/v2/upload", fileContent)) - { - response.EnsureSuccessStatusCode(); - var jsonDoc = await response.Content.ReadFromJsonAsync(); - return jsonDoc.RootElement.GetProperty("upload_url").GetString(); - } - } - -} - -async Task CreateTranscriptAsync(string audioUrl, HttpClient httpClient) -{ -var data = new { audio_url = audioUrl, webhook_url = "https://example.com/webhook" }; -var content = new StringContent(JsonSerializer.Serialize(data), Encoding.UTF8, "application/json"); - - using (var response = await httpClient.PostAsync("https://api.assemblyai.com/v2/transcript", content)) - { - response.EnsureSuccessStatusCode(); - return await response.Content.ReadFromJsonAsync(); - } - -} - -// Main execution -using (var httpClient = new HttpClient()) -{ -httpClient.DefaultRequestHeaders.Authorization = -new AuthenticationHeaderValue(""); - - var uploadUrl = await UploadFileAsync("my-audio.mp3", httpClient); - var transcript = await CreateTranscriptAsync(uploadUrl, httpClient); - -} - -``` - - - - -To create a webhook, set the `webhook_url` parameter when you create a new transcription. The URL must be accessible from AssemblyAI's servers. - -```ruby -require 'net/http' -require 'json' - -base_url = 'https://api.assemblyai.com' - -headers = { - 'authorization' => '', - 'content-type' => 'application/json' -} - -path = "./my-audio.mp3" -uri = URI("#{base_url}/v2/upload") -request = Net::HTTP::Post.new(uri, headers) -request.body = File.read(path) - -http = Net::HTTP.new(uri.host, uri.port) -http.use_ssl = true -upload_response = http.request(request) -upload_url = JSON.parse(upload_response.body)["upload_url"] - -data = { - "audio_url" => upload_url, - "webhook_url" => "https://example.com/webhook" -} - -uri = URI.parse("#{base_url}/v2/transcript") -http = Net::HTTP.new(uri.host, uri.port) -http.use_ssl = true - -request = Net::HTTP::Post.new(uri.request_uri, headers) -request.body = data.to_json - -response = http.request(request) -response_body = JSON.parse(response.body) - -unless response.is_a?(Net::HTTPSuccess) - raise "API request failed with status #{response.code}: #{response.body}" -end - -transcript_id = response_body['id'] -puts "Transcript ID: #{transcript_id}" - -polling_endpoint = URI.parse("#{base_url}/v2/transcript/#{transcript_id}") - -while true - polling_http = Net::HTTP.new(polling_endpoint.host, polling_endpoint.port) - polling_http.use_ssl = true - polling_request = Net::HTTP::Get.new(polling_endpoint.request_uri, headers) - polling_response = polling_http.request(polling_request) - - transcription_result = JSON.parse(polling_response.body) - - if transcription_result['status'] == 'completed' - puts "Transcription text: #{transcription_result['text']}" - break - elsif transcription_result['status'] == 'error' - raise "Transcription failed: #{transcription_result['error']}" - else - puts 'Waiting for transcription to complete...' - sleep(3) - end -end -``` - - - - -```php -", -"content-type: application/json" -); - -$path = "./my-audio.mp3"; - -$ch = curl_init(); - -curl_setopt($ch, CURLOPT_URL, $base_url . "/v2/upload"); -curl_setopt($ch, CURLOPT_POST, 1); -curl_setopt($ch, CURLOPT_POSTFIELDS, file_get_contents($path)); -curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); -curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - -$response = curl_exec($ch); -$response_data = json_decode($response, true); -$upload_url = $response_data["upload_url"]; - -curl_close($ch); - -$data = array( -"audio_url" => $upload_url, -"webhook_url" => "https://example.com/webhook" -); - -$url = $base_url . "/v2/transcript"; -$curl = curl_init($url); - -curl_setopt($curl, CURLOPT_POST, true); -curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($data)); -curl_setopt($curl, CURLOPT_HTTPHEADER, $headers); -curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); - -$response = curl_exec($curl); - -$response = json_decode($response, true); - -curl_close($curl); - -``` - @@ -329,6 +140,8 @@ curl_close($curl); When the transcript is ready, AssemblyAI will send a `POST` HTTP request to the URL that you specified. +Your webhook endpoint must return a 2xx HTTP status code within 10 seconds to indicate successful receipt. If a 2xx status is not received within 10 seconds, AssemblyAI will retry the webhook call up to a total of 10 attempts. If at any point your endpoint returns a 4xx status code, the webhook call is considered failed and will not be retried. + AssemblyAI sends all webhook deliveries from fixed IP addresses: @@ -356,6 +169,10 @@ The webhook delivery payload contains a JSON object with the following propertie | `transcript_id` | string | The ID of the transcript. | | `status` | string | The status of the transcript. Either `completed` or `error`. | +### Webhook status code + +When you retrieve the transcript using the transcript ID, the JSON response will include a `webhook_status_code` field. This field contains the HTTP status code that AssemblyAI received when calling your webhook endpoint, allowing you to verify that the webhook was delivered successfully. + ### Retrieve a transcript with the transcript ID @@ -449,119 +266,6 @@ if (transcriptionResult.status === "completed") { ``` - - -```csharp -using System; -using System.IO; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading.Tasks; - -public class Transcript -{ -public string Id { get; set; } -public string Status { get; set; } -public string Text { get; set; } -public string Error { get; set; } -} - -async Task RetrieveTranscript(string transcriptId, HttpClient httpClient) -{ -var pollingEndpoint = $"https://api.assemblyai.com/v2/transcript/{transcriptId}"; - var pollingResponse = await httpClient.GetAsync(pollingEndpoint); - var transcript = await pollingResponse.Content.ReadFromJsonAsync(); - switch (transcript.Status) - { - case "completed": - return transcript; - case "error": - throw new Exception($"Transcription failed: {transcript.Error}"); -default: -throw new Exception("This code should not be reachable."); -} -} - -// Main execution -using (var httpClient = new HttpClient()) -{ -httpClient.DefaultRequestHeaders.Authorization = -new AuthenticationHeaderValue(""); -var transcript = await RetrieveTranscript("", httpClient); -} - -``` - - - - -```ruby -require 'net/http' -require 'json' - -base_url = 'https://api.assemblyai.com' - -headers = { - 'authorization' => '', -} - -transcript_id = "" -polling_endpoint = URI.parse("#{base_url}/v2/transcript/#{transcript_id}") - -polling_http = Net::HTTP.new(polling_endpoint.host, polling_endpoint.port) -polling_http.use_ssl = true -polling_request = Net::HTTP::Get.new(polling_endpoint.request_uri, headers) -polling_response = polling_http.request(polling_request) - -transcription_result = JSON.parse(polling_response.body) - -if transcription_result['status'] == 'completed' - puts "Transcription text: #{transcription_result['text']}" -elsif transcription_result['status'] == 'error' - raise "Transcription failed: #{transcription_result['error']}" -else - puts 'Waiting for transcription to complete...' - sleep(3) -end -``` - - - - -```php -", -"content-type: application/json" -); - -$transcript_id = ""; -$polling_endpoint = "https://api.assemblyai.com/v2/transcript/" . $transcript_id; - -$polling_response = curl_init($polling_endpoint); -curl_setopt($polling_response, CURLOPT_HTTPHEADER, $headers); -curl_setopt($polling_response, CURLOPT_RETURNTRANSFER, true); - -$transcription_result = json_decode(curl_exec($polling_response), true); - -if ($transcription_result['status'] === "completed") { - echo $transcription_result['text']; -} else if ($transcription_result['status'] === "error") { -throw new Exception("Transcription failed: " . $transcription_result['error']); -} - -``` - - ## Authenticate webhook deliveries