Search Documentation
Search across all documentation pages
Webhook Integration

Webhook Integration

This guide walks through a complete webhook integration using the official SDKs: register a signed HTTPS endpoint, stand up a receiver that verifies every delivery, dispatch on the event type, make handling idempotent, test it, and rotate the signing secret safely.

For the conceptual model — the event catalog, the wire format, and the signature algorithm — see Webhooks. For the raw RPC surface, see the Webhooks API reference.

How it works

  1. You register an HTTPS endpoint on an App and subscribe it to the event types you care about.
  2. Transcodely returns a signing secret (whsec_…) once. You store it.
  3. When a matching event fires, Transcodely POSTs a signed JSON envelope to your URL with a Webhook-Id header (the event ID) and a Transcodely-Signature header.
  4. Your handler verifies the signature, processes the event, and replies 2xx.
  5. Non-2xx replies (or timeouts past 10 seconds) are retried on a curve — up to 15 attempts over ~3 days.

Before you start

You’ll need an app-scoped API key (ak_live_… or ak_test_…) and one of the official SDKs installed:

go get github.com/transcodely/transcodely-go
pip install transcodely
npm install transcodely

See SDKs & Libraries for setup details.

1. Create an endpoint

Register the URL that will receive deliveries and subscribe it to events. The response carries the signing secret exactly once — store it in your secret manager (e.g. as WEBHOOK_SECRET).

package main

import (
	"context"
	"fmt"
	"log"
	"os"

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

func main() {
	client, err := transcodely.New(os.Getenv("TRANSCODELY_API_KEY"))
	if err != nil {
		log.Fatal(err)
	}

	endpoint, err := client.WebhookEndpoints.Create(context.Background(), &transcodely.WebhookEndpointCreateParams{
		AppId:         "app_default000",
		Url:           "https://example.com/webhooks/transcodely",
		EnabledEvents: []string{"job.succeeded", "job.failed", "output.ready"},
		Metadata:      map[string]string{"env": "prod"},
	})
	if err != nil {
		log.Fatal(err)
	}

	// endpoint.GetSecret() is the whsec_… signing secret — returned ONLY here.
	fmt.Println("endpoint:", endpoint.GetId())
	fmt.Println("secret:  ", endpoint.GetSecret())
}
import os

from transcodely import Transcodely

with Transcodely(api_key=os.environ["TRANSCODELY_API_KEY"]) as client:
    endpoint = client.webhook_endpoints.create(
        app_id="app_default000",
        url="https://example.com/webhooks/transcodely",
        enabled_events=["job.succeeded", "job.failed", "output.ready"],
        metadata={"env": "prod"},
    )

    # endpoint.secret is the whsec_… signing secret — returned ONLY here.
    print("endpoint:", endpoint.id)
    print("secret:  ", endpoint.secret)
import { Transcodely } from "transcodely";

const client = new Transcodely({ apiKey: process.env.TRANSCODELY_API_KEY! });

const endpoint = await client.webhookEndpoints.create({
  appId: "app_default000",
  url: "https://example.com/webhooks/transcodely",
  enabledEvents: ["job.succeeded", "job.failed", "output.ready"],
  metadata: { env: "prod" },
});

// endpoint.secret is the whsec_… signing secret — returned ONLY here.
console.log("endpoint:", endpoint.id);
console.log("secret:  ", endpoint.secret);

Tip: You can also create and manage endpoints in the Webhooks dashboard or via the API. Subscribe to "*" to receive every current and future event type.

2. Stand up a signed receiver

The SDK helper (ConstructEvent / construct_event / constructEvent) does three things in one call: it verifies the HMAC signature, enforces the timestamp tolerance (default 5 minutes), and decodes the flat envelope into a typed event. Below is a complete receiver in each language.

Verify over the raw body. The signature is computed over the exact bytes Transcodely sent. Parse the JSON only after verification — re-serializing first changes the bytes and breaks the check. In Express, that means express.raw(), not express.json().

// Verify and handle incoming webhook deliveries with a net/http receiver.
// Set WEBHOOK_SECRET to the endpoint's whsec_… secret from step 1.
package main

import (
	"io"
	"log"
	"net/http"
	"os"

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

func main() {
	secret := os.Getenv("WEBHOOK_SECRET")
	if secret == "" {
		log.Fatal("set WEBHOOK_SECRET")
	}

	http.HandleFunc("/webhooks", func(w http.ResponseWriter, r *http.Request) {
		body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
		if err != nil {
			http.Error(w, "read error", http.StatusBadRequest)
			return
		}

		event, err := transcodely.ConstructEvent(body, r.Header.Get(transcodely.SignatureHeader), secret)
		if err != nil {
			// Bad signature, stale timestamp, or malformed body. Reply non-2xx so
			// the platform marks this attempt failed and retries per its curve.
			log.Printf("invalid webhook: %v", err)
			http.Error(w, "invalid signature", http.StatusBadRequest)
			return
		}

		// Acknowledge fast with a 2xx, then do heavy work asynchronously.
		// Deduplicate on event.ID — a retry carries the same evt_ id.
		switch event.Type {
		case transcodely.EventTypeJobSucceeded:
			if job, ok := event.Job(); ok {
				log.Printf("job %s succeeded (status %s)", job.GetId(), job.GetStatus())
			}
		case transcodely.EventTypeOutputReady:
			if out, ok := event.JobOutput(); ok {
				log.Printf("output %s ready at %s", out.GetId(), out.GetOutputUrl())
			}
		default:
			log.Printf("unhandled event %s (%s)", event.Type, event.ID)
		}

		w.WriteHeader(http.StatusOK)
	})

	log.Fatal(http.ListenAndServe(":8080", nil))
}
"""Verify a signed webhook delivery and dispatch on the event type.

What matters is passing the *raw* request body (bytes, exactly as received) and the
Transcodely-Signature header to construct_event. This dependency-free WSGI handler runs
anywhere; swap in your Flask/FastAPI/Django request objects as needed.
"""

import os

from transcodely import (
    Event,
    WebhookSignatureError,
    WebhookTimestampError,
    construct_event,
)

WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"]


def handle_event(event: Event) -> None:
    if event.type == "job.succeeded":
        print(f"job {event.data.id} finished")
    elif event.type == "job.failed":
        print(f"job {event.data.id} failed")
    elif event.type == "output.ready":
        print(f"output {event.data.id} ready: {event.data.output_url}")
    else:
        # Forward-compatible: unknown future types still verify.
        print(f"unhandled event {event.type}")


def webhook_app(environ, start_response):
    length = int(environ.get("CONTENT_LENGTH") or 0)
    raw_body = environ["wsgi.input"].read(length)  # raw bytes — never re-serialize
    sig_header = environ.get("HTTP_TRANSCODELY_SIGNATURE", "")

    try:
        event = construct_event(raw_body, sig_header, WEBHOOK_SECRET)
    except (WebhookSignatureError, WebhookTimestampError) as err:
        start_response("400 Bad Request", [("Content-Type", "text/plain")])
        return [f"signature check failed: {err}".encode()]

    handle_event(event)

    # Ack quickly (2xx) so the platform marks the delivery succeeded.
    start_response("200 OK", [("Content-Type", "text/plain")])
    return [b"ok"]


if __name__ == "__main__":
    from wsgiref.simple_server import make_server

    make_server("", 8000, webhook_app).serve_forever()
import express from "express";

import {
  Transcodely,
  WebhookPayloadError,
  WebhookSignatureError,
  WebhookTimestampError,
} from "transcodely";

const client = new Transcodely({ apiKey: process.env.TRANSCODELY_API_KEY ?? "" });
const secret = process.env.WEBHOOK_SECRET ?? "";

const app = express();

app.post(
  "/webhooks/transcodely",
  // Use raw() — the signature is computed over the literal request body,
  // so JSON.parse'ing first would change the bytes and break verification.
  express.raw({ type: "application/json" }),
  (req, res) => {
    const sigHeader = req.header("transcodely-signature");
    if (!sigHeader) {
      res.status(400).send("missing transcodely-signature header");
      return;
    }

    try {
      const event = client.webhooks.constructEvent(req.body, sigHeader, secret);

      switch (event.type) {
        case "job.succeeded":
          console.log(`[${event.id}] job ${event.data.id} finished`);
          break;
        case "job.failed":
          console.warn(`[${event.id}] job ${event.data.id} failed`);
          break;
        case "output.ready":
          console.log(`[${event.id}] output ${event.data.id} ready: ${event.data.outputUrl}`);
          break;
        default:
          console.log(`[${event.id}] unhandled type ${event.type}`);
      }

      res.sendStatus(200);
    } catch (err) {
      if (err instanceof WebhookSignatureError) {
        res.status(400).send("invalid signature");
        return;
      }
      if (err instanceof WebhookTimestampError) {
        res.status(400).send("signature timestamp out of tolerance");
        return;
      }
      if (err instanceof WebhookPayloadError) {
        res.status(400).send("invalid envelope shape");
        return;
      }
      throw err;
    }
  },
);

app.listen(4242, () => console.log("listening on :4242"));

If verification fails, reply with a non-2xx status (e.g. 400). The platform treats that as a failed attempt and retries — so a transient bug on your side won’t silently drop events.

3. Dispatch on the event type

event.type is the discriminator; event.data is the resource snapshot. The decoded data type follows the event group:

Event prefixdata resourceSDK accessor
job.*JobGo event.Job() · Py/JS event.data
output.*JobOutputGo event.JobOutput() · Py/JS event.data
video.*VideoGo event.Video() · Py/JS event.data
app.*AppGo event.App() · Py/JS event.data

In Go, the typed accessors return (resource, ok). In TypeScript, event.data is automatically narrowed inside a switch (event.type). In Python, check event.type (or isinstance(event.data, Job)).

Always include a default branch: unknown future event types still verify and decode, so a forward-compatible handler won’t break when new events ship. See the full event catalog.

4. Acknowledge fast, process asynchronously

Each attempt has a 10-second budget — your endpoint must return a 2xx within it. Don’t do slow work (transcode follow-ups, emails, DB-heavy joins) inline. Acknowledge immediately, enqueue the event, and process it in a background worker. A slow handler causes timeouts, which the platform counts as failures and retries.

5. Make handling idempotent

Retries — and a manual resend — deliver the same event ID. The same ID arrives in both the Webhook-Id header and event.id. Persist processed IDs and skip duplicates:

if (await alreadyProcessed(event.id)) {
  res.sendStatus(200); // ack, but don't act twice
  return;
}
await markProcessed(event.id);
// … enqueue work …
if already_processed(event.id):
    start_response("200 OK", [("Content-Type", "text/plain")])
    return [b"ok"]  # ack, but don't act twice
mark_processed(event.id)
# … enqueue work …
if alreadyProcessed(event.ID) {
	w.WriteHeader(http.StatusOK) // ack, but don't act twice
	return
}
markProcessed(event.ID)
// … enqueue work …

6. Test your endpoint

SendTest delivers a synthetic event of the type you choose through the normal signed pipeline, and returns the resulting delivery record to inspect. Test events are invisible to event listing and never bump pending-webhook counters. Rate limit: 10/min per endpoint.

delivery, err := client.WebhookEndpoints.SendTest(context.Background(), "whe_a1b2c3d4e5f6", transcodely.EventTypeJobSucceeded)
if err != nil {
	log.Fatal(err)
}
log.Printf("test delivery %s → status %s", delivery.GetId(), delivery.GetStatus())
delivery = client.webhook_endpoints.send_test("whe_a1b2c3d4e5f6", "job.succeeded")
print("test delivery", delivery.id, "→", delivery.status)
const delivery = await client.webhookEndpoints.sendTest("whe_a1b2c3d4e5f6", "job.succeeded");
console.log("test delivery", delivery.id, "→", delivery.status);

You can also click Send test on the endpoint in the dashboard.

7. Handle retries and failures

A non-2xx reply or a timeout triggers the retry curve — 15 attempts spanning ~3 days (immediate, +1m, +5m, +15m, +30m, +1h, +2h, +4h, +8h, +12h, +24h, +36h, +48h, +60h, +72h). See the full retry policy.

If an endpoint accumulates 10 consecutive failed deliveries spanning at least 72 hours, it is auto-disabled (disabled_reason: "auto_failures") until you re-enable it. Monitor delivery health so you catch problems before that happens:

health, err := client.WebhookEndpoints.GetHealth(context.Background(), "whe_a1b2c3d4e5f6", transcodely.HealthWindow24h)
if err != nil {
	log.Fatal(err)
}
log.Printf("24h success rate: %.1f%% (%d ok / %d failed)",
	health.GetSuccessRate()*100, health.GetSucceeded(), health.GetFailed())
health = client.webhook_endpoints.get_health("whe_a1b2c3d4e5f6", "24h")
print(f"24h success rate: {health.success_rate * 100:.1f}% "
      f"({health.succeeded} ok / {health.failed} failed)")
const health = await client.webhookEndpoints.getHealth("whe_a1b2c3d4e5f6", "24h");
console.log(`24h success rate: ${(health.successRate * 100).toFixed(1)}% ` +
  `(${health.succeeded} ok / ${health.failed} failed)`);

Inspect individual attempts — including response_status, latency_ms, and transport_error — with ListDeliveries.

8. Rotate the signing secret safely

Rotating issues a new secret while keeping the previous one valid for signing for 24 hours. During that overlap the Transcodely-Signature header carries two v1= signatures (one per secret). To survive rotation with zero downtime, verify against both secrets through the overlap window — no redeploy at the exact rotation moment required:

event, err := transcodely.ConstructEventWithSecrets(
	body, r.Header.Get(transcodely.SignatureHeader),
	[]string{newSecret, previousSecret})
event = construct_event(raw_body, sig_header, [new_secret, previous_secret])
const event = client.webhooks.constructEvent(req.body, sigHeader, [newSecret, previousSecret]);

Trigger a rotation with client.WebhookEndpoints.RotateSecret(…) / client.webhook_endpoints.rotate_secret(…) / client.webhookEndpoints.rotateSecret(…), or from the dashboard. See Rotating the signing secret.

Local development

Transcodely only delivers to public HTTPS URLs — private and loopback addresses are rejected at registration. To receive events on your laptop, expose your local server through a tunnel (e.g. cloudflared tunnel, ngrok http) and register the tunnel’s HTTPS URL as the endpoint. Use SendTest to drive deliveries without running real jobs.

Production checklist

  • Verify every delivery with the SDK helper and reject anything that fails.
  • Reply 2xx within 10 seconds; do heavy work in a background job.
  • Deduplicate on event.id — retries and resends reuse it.
  • Use HTTPS with a publicly resolvable host (required at registration).
  • Subscribe narrowly to only the events you consume.
  • Store the secret in a secret manager; verify against [new, previous] during rotation.
  • Monitor health and alert before an endpoint auto-disables.