Overview
Webhooks let Transcodely push status updates to your application instead of requiring you to poll the API. When a job reaches a terminal state (completed, failed, canceled, or partial), Transcodely sends an HTTP POST request to the URL you specify.
This is the recommended way to handle job completion in production systems. Webhooks are more efficient than polling, reduce API calls, and let you react to events immediately.
Setting Up Webhooks
Per-Job Webhooks
Specify a webhook_url when creating a job:
curl -X POST https://api.transcodely.com/transcodely.v1.JobService/Create
-H "Content-Type: application/json"
-H "Authorization: Bearer {{API_KEY}}"
-H "X-Organization-ID: {{ORG_ID}}"
-d '{
"input_origin_id": "ori_input12345",
"input_path": "uploads/source.mp4",
"output_origin_id": "ori_output6789",
"webhook_url": "https://yourapp.com/webhooks/transcodely",
"outputs": [
{
"type": "mp4",
"video": [{ "codec": "h264", "resolution": "1080p", "quality": "standard" }]
}
]
}'Your endpoint must be publicly accessible and respond with a 2xx status code within 30 seconds.
Event Types
Transcodely sends webhooks for the following events:
| Event | Description | When Sent |
|---|---|---|
job.completed | All outputs finished successfully | Job status is completed |
job.failed | Job failed with an error | Job status is failed |
job.canceled | Job was canceled by the user | Job status is canceled |
job.partial | Some outputs succeeded, others failed | Job status is partial |
job.awaiting_confirmation | Delayed job is ready for review | Job status is awaiting_confirmation |
Webhook Payload
Each webhook delivery is a POST request with a JSON body containing the full job object:
{
"event": "job.completed",
"job": {
"id": "job_a1b2c3d4e5f6",
"status": "completed",
"progress": 100,
"priority": "standard",
"total_estimated_cost": 0.12,
"total_actual_cost": 0.118,
"currency": "EUR",
"input_url": "",
"input_origin": {
"id": "ori_input12345",
"name": "Upload Bucket",
"provider": "gcs",
"path": "uploads/source.mp4",
"bucket": "my-uploads-bucket"
},
"outputs": [
{
"id": "out_xyz789",
"status": "completed",
"progress": 100,
"output_url": "gs://my-cdn-bucket/videos/2026-02-28/job_a1b2c3d4e5f6/1080p.mp4",
"output_size_bytes": 15728640,
"duration_seconds": 120,
"actual_cost": 0.118
}
],
"metadata": {},
"created_at": "2026-02-28T10:30:00Z",
"completed_at": "2026-02-28T10:35:42Z"
},
"timestamp": "2026-02-28T10:35:42Z"
}Signature Verification
Every webhook request includes an HMAC-SHA256 signature in the X-Transcodely-Signature header. Always verify this signature to confirm the webhook came from Transcodely and was not tampered with.
The signature is computed over the raw request body using your webhook signing secret.
Header Format
X-Transcodely-Signature: sha256=a1b2c3d4e5f6...
X-Transcodely-Timestamp: 1709136942
X-Transcodely-Delivery-ID: dlv_x1y2z3w4v5u6| Header | Description |
|---|---|
X-Transcodely-Signature | HMAC-SHA256 hex digest prefixed with sha256= |
X-Transcodely-Timestamp | Unix timestamp when the webhook was sent |
X-Transcodely-Delivery-ID | Unique delivery ID for deduplication |
Verification Steps
- Extract the timestamp and signature from the headers
- Concatenate the timestamp and request body with a
.separator:{timestamp}.{body} - Compute the HMAC-SHA256 of that string using your webhook signing secret
- Compare the computed signature with the one in the header (use constant-time comparison)
- Optionally, reject requests where the timestamp is more than 5 minutes old to prevent replay attacks
import hashlib
import hmac
import time
WEBHOOK_SECRET = "whsec_your_signing_secret"
def verify_webhook(request):
signature = request.headers.get("X-Transcodely-Signature", "")
timestamp = request.headers.get("X-Transcodely-Timestamp", "")
body = request.body.decode("utf-8")
# Check timestamp freshness (5-minute window)
if abs(time.time() - int(timestamp)) > 300:
return False
# Compute expected signature
signed_payload = f"{timestamp}.{body}"
expected = hmac.new(
WEBHOOK_SECRET.encode("utf-8"),
signed_payload.encode("utf-8"),
hashlib.sha256,
).hexdigest()
# Constant-time comparison
expected_sig = f"sha256={expected}"
return hmac.compare_digest(expected_sig, signature)package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"math"
"strconv"
"time"
)
func VerifyWebhook(body []byte, signature, timestamp, secret string) bool {
// Check timestamp freshness
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
if math.Abs(float64(time.Now().Unix()-ts)) > 300 {
return false
}
// Compute expected signature
signedPayload := fmt.Sprintf("%s.%s", timestamp, string(body))
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signedPayload))
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}import { createHmac, timingSafeEqual } from "crypto";
const WEBHOOK_SECRET = "whsec_your_signing_secret";
function verifyWebhook(body: string, signature: string, timestamp: string): boolean {
// Check timestamp freshness (5-minute window)
const age = Math.abs(Date.now() / 1000 - parseInt(timestamp, 10));
if (age > 300) return false;
// Compute expected signature
const signedPayload = `${timestamp}.${body}`;
const expected = createHmac("sha256", WEBHOOK_SECRET)
.update(signedPayload)
.digest("hex");
const expectedSig = `sha256=${expected}`;
// Constant-time comparison
return timingSafeEqual(Buffer.from(expectedSig), Buffer.from(signature));
}Handling Webhook Events
Basic Endpoint (Node.js / Express)
import express from "express";
const app = express();
app.use(express.raw({ type: "application/json" }));
app.post("/webhooks/transcodely", (req, res) => {
const signature = req.headers["x-transcodely-signature"] as string;
const timestamp = req.headers["x-transcodely-timestamp"] as string;
const body = req.body.toString();
if (!verifyWebhook(body, signature, timestamp)) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(body);
switch (event.event) {
case "job.completed":
handleJobCompleted(event.job);
break;
case "job.failed":
handleJobFailed(event.job);
break;
case "job.partial":
handleJobPartial(event.job);
break;
case "job.canceled":
handleJobCanceled(event.job);
break;
case "job.awaiting_confirmation":
handleJobAwaitingConfirmation(event.job);
break;
}
// Respond with 200 to acknowledge receipt
res.status(200).send("OK");
});Handling Completion
async function handleJobCompleted(job: any) {
for (const output of job.outputs) {
// Update your database with the output URL
await db.videos.update({
where: { jobOutputId: output.id },
data: {
status: "ready",
url: output.output_url,
fileSize: output.output_size_bytes,
cost: output.actual_cost,
},
});
}
// Notify the user
await notifyUser(job.metadata.user_id, {
message: `Video transcoding complete. Cost: ${job.total_actual_cost} ${job.currency}`,
});
}Retry Behavior
If your endpoint returns a non-2xx status code or times out, Transcodely retries the delivery with exponential backoff:
| Attempt | Delay After Failure |
|---|---|
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 12 hours |
After 6 failed attempts, the delivery is marked as failed. Each retry includes the same X-Transcodely-Delivery-ID header so you can deduplicate.
Idempotent Processing
Webhooks may be delivered more than once. Always process events idempotently by using the X-Transcodely-Delivery-ID header:
app.post("/webhooks/transcodely", async (req, res) => {
const deliveryId = req.headers["x-transcodely-delivery-id"] as string;
// Check if already processed
const existing = await db.webhookDeliveries.findUnique({
where: { deliveryId },
});
if (existing) {
return res.status(200).send("Already processed");
}
// Process the event
const event = JSON.parse(req.body.toString());
await processEvent(event);
// Record the delivery
await db.webhookDeliveries.create({
data: { deliveryId, processedAt: new Date() },
});
res.status(200).send("OK");
});Best Practices
| Practice | Rationale |
|---|---|
| Always verify signatures | Prevents accepting forged webhook payloads |
| Respond with 200 quickly | Process events asynchronously to avoid timeouts |
| Use delivery ID for deduplication | Webhooks may be retried and delivered more than once |
| Check timestamp freshness | Prevents replay attacks with old webhook payloads |
| Log all webhook deliveries | Helps debug integration issues |
| Use HTTPS endpoints only | Protects webhook data in transit |
| Store raw payloads | Useful for debugging and auditing |