> ## Documentation Index > Fetch the complete documentation index at: https://bunny.net/docs/llms.txt > Use this file to discover all available pages before exploring further. # Webhooks > Bunny Stream allows you to configure a secure webhook URL. Our system will automatically send notifications back to this URL once a video status changes. This allows you to track the updates and progress of the video processing cycles on your side. Configure a webhook URL on your library to receive a POST notification every time a video changes state — for example, when uploading completes or transcoding finishes. The sections below describe the callback payload, status codes, delivery, and signature verification. ## Callback When the video state changes, a JSON callback will be sent back to your URL as a POST request. Below is an example object that will be sent. ```json JSON theme={null} { "IsLiveStreamWebhook": false, "VideoLibraryId": 133, "VideoGuid": "657bb740-a71b-4529-a012-528021c31a92", "Status": 3, "EventId": "0f8e2c52-6a3e-4c1f-9d55-2b8c4f6c2a10", "Timestamp": "2026-10-01T12:30:15.1234567Z" } ``` The callback contains the following fields: | Field | Description | | - | - | | `IsLiveStreamWebhook` | `false` for video webhooks. | | `VideoLibraryId` | The ID of the video library. | | `VideoGuid` | The ID of the video. | | `Status` | The status code of the event. See the [Status list](#status-list). | | `EventId` | A unique ID for the event. It stays the same when a webhook is [delivered again](#delivery-and-retries), so use it to drop duplicate deliveries. | | `Timestamp` | The UTC time the event happened, not the time the webhook was delivered. Deliveries can arrive out of order, so use it to order the events of a video. | | `Reason` | Extra detail for statuses that have one, such as `SmartGenerationFailed`. Left out when there is none. | > [!NOTE] > > `IsLiveStreamWebhook`, `EventId`, `Timestamp`, and `Reason` were added to an existing payload. If your receiver ignores fields it doesn't know, it keeps working without changes. Switch on the numeric `Status` value, not on the status name. ## Status list Below are the possible status codes sent with the webhook: * 0 - **Queued**: The video has been queued for encoding. * 1 - **Processing**: The video has begun processing the preview and format details. * 2 - **Encoding**: The video is encoding. * 3 - **Finished**: The video encoding has finished and the video is fully available. * 4 - **Resolution finished**: The encoder has finished processing one of the resolutions. The first request also signals that the video is now playable. * 5 - **Failed**: The video encoding failed. The video has finished processing. * 6 - **PresignedUploadStarted** : A pre-signed upload has been initiated. * 7 - **PresignedUploadFinished** : A pre-signed upload has been completed. * 8 - **PresignedUploadFailed** : A pre-signed upload has failed. * 9 - **CaptionsGenerated** : Automatic captions were generated. * 10 - **TitleOrDescriptionGenerated** : Automatic generation of title or description has been completed. * 11 - **CaptionGenerationFailed** : Automatic caption generation has failed. * 12 - **ChaptersGenerated** : Automatic chapters were generated. * 13 - **MomentsGenerated** : Automatic moments were generated. * 32 - **VideoDeleted** : The video was deleted, including when it was deleted together with its collection. Sent once per deleted video. * 33 - **SmartGenerationFailed** : A requested smart generation feature failed. `Reason` names the feature: `Title`, `Description`, `Chapters`, or `Moments`. One webhook is sent for each failed feature. Features that completed before the failure still send their own generated webhook. ## Delivery and retries Each webhook is first sent right after the event happens. Your endpoint confirms delivery by responding with any `2xx` status code. If a delivery fails with a network error, a timeout, or a `408`, `429`, or `5xx` response, it is retried after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, and 2 hours. After the last attempt, about four hours after the event, the webhook is dropped. Other `4xx` responses are not retried. * **Same body on every attempt**: A retry sends the original body, so `EventId` and `Timestamp` don't change. Use `EventId` to ignore a webhook you already processed. * **Order by `Timestamp`**: A retried webhook can arrive after a newer event for the same video. Compare `Timestamp` values instead of relying on arrival order. * **Current settings apply**: Every attempt goes to the library's current webhook URL and is signed with its current Read-Only API key. If you remove the webhook URL, pending retries are dropped. * **Respond quickly**: Return a `2xx` response as soon as you've received the webhook, and do slow processing afterwards, so the request doesn't time out and get retried. ## Signature validation Verify that an incoming webhook request was sent by Bunny Stream and has not been tampered with. ### Overview Signed webhook POSTs use signature version **`v1`**. Each signed request includes the following headers: | Header | Value | | - | - | | `X-BunnyStream-Signature-Version` | `v1` | | `X-BunnyStream-Signature-Algorithm` | `hmac-sha256` | | `X-BunnyStream-Signature` | Lowercase hex HMAC-SHA256 (64 characters) | The signature is an **HMAC-SHA256** of the exact raw request body, using the webhook signing secret as the key, encoded as a **lowercase hex string**. > [!NOTE] > > [The signing secret is your library's **Read-Only API key**. Validation must use the **exact raw body** as received — do not parse and re-serialize the JSON body.](https://bunny.net/docs/api-reference/core/stream-video-library/get-video-library.md#response-read-only-api-key-one-of-0) ### How the signature is built ```text theme={null} version = "v1" algorithm = "hmac-sha256" signature = lowercase_hex( HMAC-SHA256( utf8(body), utf8(signatureSecret) ) ) ``` * **body**: The exact raw HTTP request body bytes as received. * **signatureSecret**: Your library's [**Read-Only API key**](https://bunny.net/docs/api-reference/core/stream-video-library/get-video-library.md#response-read-only-api-key-one-of-0), encoded as UTF-8. * **Output**: 64-character lowercase hexadecimal string. * The URL, HTTP method, and headers are **not** part of the `v1` signature. The body, including its `EventId` and `Timestamp` fields, is. ### Validation steps **1. Read the raw request body** Capture the raw body from the HTTP request stream before any parsing. Do **not** parse to JSON and re-serialize. **2. Check the version header** Ensure `X-BunnyStream-Signature-Version` is `v1`. **3. Check the algorithm header** Ensure `X-BunnyStream-Signature-Algorithm` is `hmac-sha256`. **4. Get your signing secret** Use your library's **Read-Only API key** as the signing secret. **5. Compute the expected signature** `expectedSignature = lowercase_hex(HMAC-SHA256(rawBody, signatureSecret))` **6. Compare signatures** Read `X-BunnyStream-Signature` and compare it to your computed value using a **constant-time comparison** to avoid timing attacks. If they match, the request is authentic and the body was not modified in transit. ### Code examples ```javascript Node.js theme={null} const crypto = require('crypto'); function validateWebhookSignature(rawBody, signatureHeader, signatureVersion, signatureAlgorithm, signatureSecret) { if (signatureVersion !== 'v1') { return false; } if (signatureAlgorithm !== 'hmac-sha256') { return false; } const expectedHex = crypto .createHmac('sha256', signatureSecret) .update(rawBody, 'utf8') .digest('hex'); if ( typeof signatureHeader !== 'string' || signatureHeader.length !== expectedHex.length || !/^[0-9a-f]+$/.test(signatureHeader) ) { return false; } return crypto.timingSafeEqual( Buffer.from(expectedHex, 'utf8'), Buffer.from(signatureHeader, 'utf8') ); } // Express: use body parser that keeps raw body app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => { const signature = req.headers['x-bunnystream-signature']; const version = req.headers['x-bunnystream-signature-version']; const algorithm = req.headers['x-bunnystream-signature-algorithm']; const rawBody = req.body.toString('utf8'); if (!validateWebhookSignature(rawBody, signature, version, algorithm, process.env.READ_ONLY_API_KEY)) { return res.status(401).send('Invalid signature'); } const data = JSON.parse(rawBody); // ... handle webhook }); ``` ```python Python theme={null} import os import hmac import hashlib from flask import request def validate_webhook_signature( raw_body: bytes, signature_header: str, signature_version: str, signature_algorithm: str, signature_secret: str, ) -> bool: if signature_version != "v1": return False if signature_algorithm != "hmac-sha256": return False expected = hmac.new( signature_secret.encode('utf-8'), raw_body, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, signature_header) @app.route('/webhook', methods=['POST']) def webhook(): raw_body = request.get_data() signature = request.headers.get('X-BunnyStream-Signature', '') signature_version = request.headers.get('X-BunnyStream-Signature-Version', '') signature_algorithm = request.headers.get('X-BunnyStream-Signature-Algorithm', '') if not validate_webhook_signature(raw_body, signature, signature_version, signature_algorithm, os.environ['READ_ONLY_API_KEY']): return '', 401 data = request.get_json() # ... handle webhook ``` ```csharp C# theme={null} using System.Security.Cryptography; using System.Text; bool ValidateWebhookSignature( string rawBody, string signatureHeader, string signatureVersion, string signatureAlgorithm, string signatureSecret) { if (!string.Equals(signatureVersion, "v1", StringComparison.Ordinal)) { return false; } if (!string.Equals(signatureAlgorithm, "hmac-sha256", StringComparison.Ordinal)) { return false; } byte[] keyBytes = Encoding.UTF8.GetBytes(signatureSecret); byte[] bodyBytes = Encoding.UTF8.GetBytes(rawBody); using var hmac = new HMACSHA256(keyBytes); byte[] hash = hmac.ComputeHash(bodyBytes); string expected = Convert.ToHexString(hash).ToLowerInvariant(); return CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(signatureHeader)); } ``` ### Important notes > [!WARNING] > > Always use the **exact raw body bytes** for validation. If your framework parses JSON and re-serializes it, whitespace or key ordering may change and the signature will not match. * **Constant-time comparison**: Use `timingSafeEqual`, `hmac.compare_digest`, or `CryptographicOperations.FixedTimeEquals` so comparison time does not leak information about the secret. * **Signing secret**: Use your library's **Read-Only API key** as the signing secret. Keep it server-side only; never expose it to the client. * **Version & algorithm headers**: Always validate both `X-BunnyStream-Signature-Version` and `X-BunnyStream-Signature-Algorithm` before computing the signature. This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.