Search Documentation
Search across all documentation pages
Webhooks

Webhooks

Webhooks let your application receive real-time HTTP notifications when events happen — a job completes, an output goes live, a video is uploaded. Instead of polling the API, Transcodely pushes signed events to your server as they happen.

How it works

  1. You register an HTTPS endpoint on an App and choose which event types you want.
  2. Transcodely returns a signing secret (whsec_…) once. Store it securely.
  3. Whenever a matching event fires, Transcodely POSTs a signed JSON envelope to your endpoint with two custom headers: Webhook-Id (event ID) and Transcodely-Signature.
  4. Your handler verifies the signature, processes the event, and replies with a 2xx status code.
  5. If your endpoint returns a non-2xx (or times out after 10 seconds), Transcodely retries on a curve — up to 15 attempts spanning roughly 72 hours.

You can manage endpoints in the Webhooks dashboard or via the WebhookService API. For an end-to-end, SDK-based walkthrough, see the Webhook Integration guide.

Setup

In the dashboard, go to Webhooks → Create endpoint. You’ll need:

  • Endpoint URL — a public HTTPS URL. Private/loopback IPs are rejected.
  • Events — pick a subset, or subscribe to * to receive all current and future event types.
  • Description / metadata (optional) — for your own bookkeeping.

When the endpoint is created, the dashboard shows the signing secret once. Copy it into your environment (e.g. WEBHOOK_SECRET). It is never shown again — only rotation issues a new one. You can also create endpoints programmatically with the SDKs or API.

Event catalog

There are 13 event types. Use * to subscribe to everything (including future event types). The * wildcard is valid only as a subscription value — it is never the type of a delivered event.

Event typeWhendata resource
job.createdA new job has been accepted.Job
job.progressAggregate job progress crossed a milestone (5, 10, 25, 50, 75, 90%).Job
job.succeededJob completed successfully.Job
job.failedJob failed (full or partial).Job
job.canceledJob was canceled.Job
output.createdAn output started processing for the first time.JobOutput
output.progressA single output crossed a milestone.JobOutput
output.readyA single output finished successfully.JobOutput
output.failedA single output failed.JobOutput
video.uploadedA video upload finalized.Video
video.deletedA video was deleted (snapshot is pre-deletion state).Video
app.createdAn app was created.App
app.updatedAn app was updated.App

Each milestone for job.progress / output.progress is emitted at most once per crossing — treat them as “we crossed N%” notifications, not exact samples.

Moving from app-level webhooks? The legacy event names job.completed and output.completed are now job.succeeded and output.ready. There is no job.updated — use the terminal events (job.succeeded / job.failed / job.canceled) plus the per-output signals.

Wire format

Transcodely sends one HTTPS POST per delivery attempt. The body is a flat JSON envelope; the resource snapshot lives inside the envelope’s data field.

POST /your-handler HTTP/1.1
Content-Type: application/json
Webhook-Id: evt_a1b2c3d4e5f6g7h8
Transcodely-Signature: t=1716480293,v1=a3f7b2…

{ /* envelope — see below */ }
  • Webhook-Id — the event ID. Use it for idempotency (retries carry the same ID).
  • Transcodely-Signature — see Signature verification.
  • The HMAC signature is computed over the raw envelope bytes — the exact body your server receives.

Envelope fields

FieldTypeNotes
idstringEvent ID (evt_…). Same as Webhook-Id header.
objectstringAlways "event". Discriminator for the envelope.
api_versionstringAPI version frozen at emit time (e.g., "2026-05-23").
createdstringRFC 3339 UTC timestamp when the event fired.
typestringEvent type (e.g., "job.succeeded").
dataobjectThe resource snapshot. Includes its own object discriminator ("job", "job_output", "video", "app").
livemodebooleanReserved. Always true today.
pending_webhooksintDelivery attempts still pending across all subscribed endpoints.
request.idstring | nullRequest ID of the API call that triggered the event, when available.
request.idempotency_keynullReserved. Always null today.

Example body — job.succeeded

{
  "id": "evt_a1b2c3d4e5f6g7h8",
  "object": "event",
  "api_version": "2026-05-23",
  "created": "2026-05-24T10:55:08Z",
  "type": "job.succeeded",
  "data": {
    "id": "job_a1b2c3d4e5f6",
    "object": "job",
    "app_id": "app_default000",
    "input_url": "gs://bucket/source.mp4",
    "status": "completed",
    "progress": 100,
    "metadata": { "customer_ref": "abc-123" },
    "created_at": "2026-05-24T10:42:11Z",
    "completed_at": "2026-05-24T10:55:08Z",
    "total_actual_cost": 0.1098,
    "currency": "USD",
    "outputs": [/* full JobOutput entries */]
  },
  "livemode": true,
  "pending_webhooks": 0,
  "request": { "id": "req_abc123", "idempotency_key": null }
}

Int64 fields inside data (output_size_bytes, duration_seconds) are JSON-encoded as strings — convert with Number(x) or BigInt(x) as needed.

The inner data.object value ("job", "job_output", "video", "app") is a stable discriminator — use it to dispatch to the right handler instead of inferring resource type from key presence.

Signature verification

Each request includes a Transcodely-Signature header:

t=<unix_seconds>,v1=<hex_hmac>[,v1=<hex_hmac>]

To verify:

  1. Parse the timestamp t and one or more v1= HMACs.
  2. Reject if |now - t| exceeds your tolerance window (default 5 minutes).
  3. Compute expected = HMAC-SHA-256(secret, t + "." + raw_body_bytes), hex-encoded. The HMAC key is the full secret string, including the whsec_ prefix.
  4. Accept if any provided v1= matches expected (use a timing-safe comparison).

During a secret rotation, Transcodely sends two v1= entries for 24 hours — one signed with the new secret, one with the old. Accept either.

Every SDK ships a one-call helper that verifies the signature, enforces the timestamp tolerance (default 300 s), and decodes the envelope into a typed event. Pass the raw request body (never a re-serialized object) and the Transcodely-Signature header value. During a rotation, pass both secrets.

import "github.com/transcodely/transcodely-go"

// One secret:
event, err := transcodely.ConstructEvent(body, sigHeader, secret)
// During rotation — accept current and previous for the 24h overlap:
event, err = transcodely.ConstructEventWithSecrets(body, sigHeader, []string{newSecret, previousSecret})
if err != nil {
	// Reject: bad signature, stale timestamp, or malformed body.
}
// event.Type, event.ID, and typed accessors: event.Job(), event.JobOutput(), ...
from transcodely import construct_event

# One secret, or a list during rotation:
event = construct_event(raw_body, sig_header, secret)
event = construct_event(raw_body, sig_header, [new_secret, previous_secret])
# event.type, event.id, event.data
import { Transcodely } from "transcodely";

const client = new Transcodely({ apiKey: "" }); // apiKey not needed just to verify
// One secret, or an array during rotation:
const event = client.webhooks.constructEvent(rawBody, sigHeader, secret);
// const event = client.webhooks.constructEvent(rawBody, sigHeader, [newSecret, previousSecret]);
// event.type narrows event.data automatically in a switch.

On failure the helper throws/returns a typed error (WebhookSignatureError, WebhookTimestampError, WebhookPayloadError). See the integration guide for complete receivers.

Verify manually (no SDK)

If your language has no SDK, the algorithm is small. These implementations are byte-for-byte compatible with the platform signer.

import crypto from 'node:crypto';

export function verifyWebhook(opts: {
  body: string | Buffer;
  signatureHeader: string;
  secrets: string[];           // current secret; during rotation, pass [current, previous]
  toleranceSeconds?: number;
}): void {
  const tolerance = opts.toleranceSeconds ?? 300;
  const bodyBuf = typeof opts.body === 'string'
    ? Buffer.from(opts.body, 'utf8')
    : opts.body;

  let ts = 0;
  const provided: string[] = [];
  for (const part of opts.signatureHeader.split(',')) {
    const [k, v] = part.trim().split('=', 2);
    if (k === 't') ts = parseInt(v, 10);
    else if (k === 'v1') provided.push(v);
  }
  if (!ts || provided.length === 0) {
    throw new Error('invalid signature header');
  }
  if (Math.abs(Date.now() / 1000 - ts) > tolerance) {
    throw new Error('timestamp outside tolerance window');
  }

  const signedPayload = Buffer.concat([Buffer.from(`${ts}.`, 'utf8'), bodyBuf]);
  for (const secret of opts.secrets) {
    const expected = crypto
      .createHmac('sha256', secret)
      .update(signedPayload)
      .digest('hex');
    for (const sig of provided) {
      const a = Buffer.from(expected, 'utf8');
      const b = Buffer.from(sig, 'utf8');
      if (a.length === b.length && crypto.timingSafeEqual(a, b)) {
        return;
      }
    }
  }
  throw new Error('no valid signature');
}
import hmac
import hashlib
import time

def verify_webhook(
    body: bytes,
    signature_header: str,
    secrets: list[str],
    tolerance: int = 300,
) -> None:
    ts = 0
    provided: list[str] = []
    for part in signature_header.split(","):
        k, _, v = part.strip().partition("=")
        if k == "t":
            ts = int(v)
        elif k == "v1":
            provided.append(v)
    if not ts or not provided:
        raise ValueError("invalid signature header")
    if abs(time.time() - ts) > tolerance:
        raise ValueError("timestamp outside tolerance window")

    signed = f"{ts}.".encode("utf-8") + body
    for secret in secrets:
        expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
        for sig in provided:
            if hmac.compare_digest(expected, sig):
                return
    raise ValueError("no valid signature")
package webhooks

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"math"
	"strconv"
	"strings"
	"time"
)

// VerifyWebhook checks a Transcodely-Signature header against the raw body.
// Pass [current] normally, or [current, previous] during a secret rotation.
func VerifyWebhook(body []byte, signatureHeader string, secrets []string, tolerance time.Duration) error {
	var ts int64
	var provided []string
	for _, part := range strings.Split(signatureHeader, ",") {
		k, v, ok := strings.Cut(strings.TrimSpace(part), "=")
		if !ok {
			continue
		}
		switch k {
		case "t":
			ts, _ = strconv.ParseInt(v, 10, 64)
		case "v1":
			provided = append(provided, v)
		}
	}
	if ts == 0 || len(provided) == 0 {
		return errors.New("invalid signature header")
	}
	if math.Abs(float64(time.Now().Unix()-ts)) > tolerance.Seconds() {
		return errors.New("timestamp outside tolerance window")
	}

	signed := append([]byte(strconv.FormatInt(ts, 10)+"."), body...)
	for _, secret := range secrets {
		mac := hmac.New(sha256.New, []byte(secret))
		mac.Write(signed)
		expected := hex.EncodeToString(mac.Sum(nil))
		for _, sig := range provided {
			if hmac.Equal([]byte(expected), []byte(sig)) {
				return nil
			}
		}
	}
	return errors.New("no valid signature")
}

Retry policy

If your endpoint returns a non-2xx (or times out after 10 seconds), Transcodely retries on this curve. Times are measured from the event’s creation:

AttemptTime from event creation
1immediate
2+1 min
3+5 min
4+15 min
5+30 min
6+1 h
7+2 h
8+4 h
9+8 h
10+12 h
11+24 h
12+36 h
13+48 h
14+60 h
15+72 h

After 15 failed attempts the delivery is terminally failed. If an endpoint sees 10 consecutive failed deliveries spanning at least 72 hours, it is auto-disabled (disabled_reason: "auto_failures") until you re-enable it manually.

You can manually resend any event or inspect delivery attempts — including latency and transport errors — from the dashboard or the API.

Rotating the signing secret

In the dashboard, open the endpoint and click Rotate signing secret (or call the API). The new secret is shown once. The previous secret remains valid for signing for 24 hours so in-flight handlers don’t break — during that window Transcodely sends two v1= entries (old + new). Update your handler to accept the new secret before the overlap closes.

If you verify against both [current, previous] (or use ConstructEventWithSecrets / pass a list to construct_event / constructEvent), you don’t need to redeploy at the exact rotation moment — the handler keeps working through the overlap.

Best practices

  • Reply fast. Acknowledge with 200 within 10 seconds. Process slow work in a background job.
  • Use Webhook-Id for idempotency. Retries carry the same ID. Persist it and skip duplicates.
  • Always verify signatures. Treat unsigned bodies as untrusted input.
  • HTTPS only. Plain HTTP URLs and private/loopback IPs are rejected at registration.
  • Subscribe narrowly. Only enable the events you actually consume — it reduces wasted traffic and noise in your logs.
  • Monitor failures. Watch the deliveries tab or GetEndpointHealth. If it auto-disables, your endpoint has been failing for at least 72 hours.