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
- You register an HTTPS endpoint on an App and choose which event types you want.
- Transcodely returns a signing secret (
whsec_…) once. Store it securely. - Whenever a matching event fires, Transcodely POSTs a signed JSON envelope to your endpoint with two custom headers:
Webhook-Id(event ID) andTranscodely-Signature. - Your handler verifies the signature, processes the event, and replies with a
2xxstatus code. - 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 type | When | data resource |
|---|---|---|
job.created | A new job has been accepted. | Job |
job.progress | Aggregate job progress crossed a milestone (5, 10, 25, 50, 75, 90%). | Job |
job.succeeded | Job completed successfully. | Job |
job.failed | Job failed (full or partial). | Job |
job.canceled | Job was canceled. | Job |
output.created | An output started processing for the first time. | JobOutput |
output.progress | A single output crossed a milestone. | JobOutput |
output.ready | A single output finished successfully. | JobOutput |
output.failed | A single output failed. | JobOutput |
video.uploaded | A video upload finalized. | Video |
video.deleted | A video was deleted (snapshot is pre-deletion state). | Video |
app.created | An app was created. | App |
app.updated | An 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.completedandoutput.completedare nowjob.succeededandoutput.ready. There is nojob.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
| Field | Type | Notes |
|---|---|---|
id | string | Event ID (evt_…). Same as Webhook-Id header. |
object | string | Always "event". Discriminator for the envelope. |
api_version | string | API version frozen at emit time (e.g., "2026-05-23"). |
created | string | RFC 3339 UTC timestamp when the event fired. |
type | string | Event type (e.g., "job.succeeded"). |
data | object | The resource snapshot. Includes its own object discriminator ("job", "job_output", "video", "app"). |
livemode | boolean | Reserved. Always true today. |
pending_webhooks | int | Delivery attempts still pending across all subscribed endpoints. |
request.id | string | null | Request ID of the API call that triggered the event, when available. |
request.idempotency_key | null | Reserved. 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:
- Parse the timestamp
tand one or morev1=HMACs. - Reject if
|now - t|exceeds your tolerance window (default 5 minutes). - Compute
expected = HMAC-SHA-256(secret, t + "." + raw_body_bytes), hex-encoded. The HMAC key is the full secret string, including thewhsec_prefix. - Accept if any provided
v1=matchesexpected(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.
Verify with an official SDK (recommended)
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.dataimport { 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:
| Attempt | Time from event creation |
|---|---|
| 1 | immediate |
| 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
200within 10 seconds. Process slow work in a background job. - Use
Webhook-Idfor 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.