Search Documentation
Search across all documentation pages
Webhook Integration

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:

EventDescriptionWhen Sent
job.completedAll outputs finished successfullyJob status is completed
job.failedJob failed with an errorJob status is failed
job.canceledJob was canceled by the userJob status is canceled
job.partialSome outputs succeeded, others failedJob status is partial
job.awaiting_confirmationDelayed job is ready for reviewJob 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
HeaderDescription
X-Transcodely-SignatureHMAC-SHA256 hex digest prefixed with sha256=
X-Transcodely-TimestampUnix timestamp when the webhook was sent
X-Transcodely-Delivery-IDUnique delivery ID for deduplication

Verification Steps

  1. Extract the timestamp and signature from the headers
  2. Concatenate the timestamp and request body with a . separator: {timestamp}.{body}
  3. Compute the HMAC-SHA256 of that string using your webhook signing secret
  4. Compare the computed signature with the one in the header (use constant-time comparison)
  5. 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:

AttemptDelay After Failure
1Immediate
21 minute
35 minutes
430 minutes
52 hours
612 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

PracticeRationale
Always verify signaturesPrevents accepting forged webhook payloads
Respond with 200 quicklyProcess events asynchronously to avoid timeouts
Use delivery ID for deduplicationWebhooks may be retried and delivered more than once
Check timestamp freshnessPrevents replay attacks with old webhook payloads
Log all webhook deliveriesHelps debug integration issues
Use HTTPS endpoints onlyProtects webhook data in transit
Store raw payloadsUseful for debugging and auditing