Webhooks
React to video processing with signed events and delivery logs.
You will receive a signed request when a video is ready or fails, or when a clone job completes. Use webhooks to update your application without polling every video.
Register an endpoint
curl --fail-with-body --request POST 'https://api.todayfiles.com/v1/webhooks' \
--header "Authorization: Bearer $TODAYFILES_API_KEY" \
--header 'Idempotency-Key: replace-with-a-unique-request-key' \
--header 'Content-Type: application/json' \
--data '{"url":"https://example.com/hooks/todayfiles","events":["video.ready"],"description":"Production"}'const response = await fetch("https://api.todayfiles.com/v1/webhooks", {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.TODAYFILES_API_KEY,
"Idempotency-Key": "replace-with-a-unique-request-key",
"Content-Type": "application/json",
},
body: JSON.stringify({"url":"https://example.com/hooks/todayfiles","events":["video.ready"],"description":"Production"}),
});
if (!response.ok) throw new Error(await response.text());
const result = response.status === 204 ? null : await response.json();
console.log(result);import json
import os
import requests
response = requests.request(
"POST", "https://api.todayfiles.com/v1/webhooks",
headers={
"Authorization": "Bearer " + os.environ["TODAYFILES_API_KEY"],
"Idempotency-Key": "replace-with-a-unique-request-key",
},
json=json.loads("{\"url\":\"https://example.com/hooks/todayfiles\",\"events\":[\"video.ready\"],\"description\":\"Production\"}"),
timeout=30,
)
response.raise_for_status()
print(response.json() if response.content else None)<?php
$client = curl_init("https://api.todayfiles.com/v1/webhooks");
curl_setopt_array($client, [
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('TODAYFILES_API_KEY'),
'Idempotency-Key: replace-with-a-unique-request-key',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => "{\"url\":\"https://example.com/hooks/todayfiles\",\"events\":[\"video.ready\"],\"description\":\"Production\"}",
]);
$body = curl_exec($client);
if ($body === false) throw new RuntimeException(curl_error($client));
$status = curl_getinfo($client, CURLINFO_RESPONSE_CODE);
curl_close($client);
if ($status >= 400) throw new RuntimeException($body);
$result = $body === '' ? null : json_decode($body, true, 512, JSON_THROW_ON_ERROR);
print_r($result);package main
import (
"encoding/json"
"io"
"net/http"
"os"
"strings"
"time"
)
func main() {
request, err := http.NewRequest("POST", "https://api.todayfiles.com/v1/webhooks", strings.NewReader("{\"url\":\"https://example.com/hooks/todayfiles\",\"events\":[\"video.ready\"],\"description\":\"Production\"}"))
if err != nil { panic(err) }
request.Header.Set("Authorization", "Bearer " + os.Getenv("TODAYFILES_API_KEY"))
request.Header.Set("Idempotency-Key", "replace-with-a-unique-request-key")
request.Header.Set("Content-Type", "application/json")
response, err := (&http.Client{Timeout: 30 * time.Second}).Do(request)
if err != nil { panic(err) }
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, 1 << 20))
if err != nil { panic(err) }
if response.StatusCode >= 400 { panic(string(body)) }
var result any
if len(body) > 0 {
if err := json.Unmarshal(body, &result); err != nil { panic(err) }
}
if err := json.NewEncoder(os.Stdout).Encode(result); err != nil { panic(err) }
}Save secret when it appears. It is shown only after creation or rotation. Put it in your server's secret manager as TODAYFILES_WEBHOOK_SECRET.
Your URL must use HTTPS, have no username or password, and use port 443 or 8443. Its host must resolve only to public addresses. Addresses are checked again at every delivery. Redirects are not followed. Each account can have up to ten endpoints.
Choose a non-empty subset of video.ready, video.failed, and clone_job.completed. A test event uses ping and does not need a subscription.
Read an event
The request body is an event object with id, type, created_at and data. video.ready and video.failed carry the complete video in data.video. clone_job.completed carries data.clone_job without its individual items. ping carries an empty data object.
Delivery is at least once. Deduplicate on event.id, commit your application update, then return a 2xx status within ten seconds. Move longer work to your own queue. Event detection and dispatch run every fifteen seconds, so allow roughly thirty seconds after processing completes.
Verify the signature
Read the raw body before JSON parsing. The TodayFiles-Signature header contains t=<unix>,v1=<hex>. Compute HMAC-SHA256 of the timestamp, a dot, and the exact raw body, with your endpoint secret. Compare in constant time. Reject timestamps more than 300 seconds away from your clock.
Rotation can produce two v1 values. Accept the request if either matches. Each function below returns false for an invalid signature or an old timestamp.
# The receiving server supplies BODY and SIGNATURE without altering their bytes.
# Keep TODAYFILES_WEBHOOK_SECRET in the environment, outside this script.
python3 -c '
import os, sys, time, hmac, hashlib, re
parts = os.environ["SIGNATURE"].split(",")
ts = [p[2:] for p in parts if p.startswith("t=")]
if len(ts) != 1 or not re.fullmatch(r"[0-9]{1,12}", ts[0]): sys.exit(1)
if abs(int(time.time()) - int(ts[0])) > 300: sys.exit(1)
body = os.environ["BODY"].encode()
secret = os.environ["TODAYFILES_WEBHOOK_SECRET"].encode()
expected = hmac.new(secret, ts[0].encode()+b"."+body, hashlib.sha256).hexdigest()
sigs = [p[3:] for p in parts if p.startswith("v1=")]
sys.exit(0 if any(re.fullmatch(r"[0-9a-f]{64}", s) and
hmac.compare_digest(expected, s) for s in sigs) else 1)
'import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, header, secret, now = Math.floor(Date.now() / 1000)) {
const parts = header.split(",");
const timestamps = parts.filter(p => p.startsWith("t=")).map(p => p.slice(2));
if (timestamps.length !== 1 || !/^[0-9]{1,12}$/.test(timestamps[0])) return false;
const timestamp = timestamps[0];
if (Math.abs(now - Number(timestamp)) > 300) return false;
const expected = createHmac("sha256", secret)
.update(timestamp + ".").update(rawBody).digest();
return parts.filter(p => p.startsWith("v1=")).some(part => {
const hex = part.slice(3);
if (!/^[0-9a-f]{64}$/.test(hex)) return false;
return timingSafeEqual(expected, Buffer.from(hex, "hex"));
});
}
// rawBody is the original Buffer. Parse JSON only after verify() returns true.import hashlib
import hmac
import re
import time
def verify(raw_body, header, secret, now=None):
parts = header.split(",")
timestamps = [p[2:] for p in parts if p.startswith("t=")]
if len(timestamps) != 1 or not re.fullmatch(r"[0-9]{1,12}", timestamps[0]):
return False
timestamp = timestamps[0]
if abs((time.time() if now is None else now) - int(timestamp)) > 300:
return False
expected = hmac.new(secret.encode(), timestamp.encode()+b"."+raw_body,
hashlib.sha256).hexdigest()
return any(re.fullmatch(r"[0-9a-f]{64}", p[3:]) and
hmac.compare_digest(expected, p[3:])
for p in parts if p.startswith("v1="))<?php
function verify(string $rawBody, string $header, string $secret, ?int $now = null): bool {
$parts = explode(',', $header);
$times = array_values(array_filter($parts, fn($p) => str_starts_with($p, 't=')));
if (count($times) !== 1) return false;
$timestamp = substr($times[0], 2);
if (!preg_match('/\A[0-9]{1,12}\z/', $timestamp)) return false;
if (abs(($now ?? time()) - (int)$timestamp) > 300) return false;
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
foreach ($parts as $part) {
if (str_starts_with($part, 'v1=')) {
$signature = substr($part, 3);
if (preg_match('/\A[0-9a-f]{64}\z/', $signature) && hash_equals($expected, $signature)) return true;
}
}
return false;
}
// $rawBody = file_get_contents('php://input'); verify it before json_decode().package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
)
func Verify(raw []byte, header, secret string, now time.Time) bool {
var timestamp string
var signatures []string
for _, part := range strings.Split(header, ",") {
key, value, _ := strings.Cut(part, "=")
if key == "t" {
if timestamp != "" { return false }
timestamp = value
}
if key == "v1" { signatures = append(signatures, value) }
}
seconds, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || seconds < now.Unix()-300 || seconds > now.Unix()+300 { return false }
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "."))
mac.Write(raw)
expected := mac.Sum(nil)
for _, value := range signatures {
signature, err := hex.DecodeString(value)
if err == nil && hmac.Equal(expected, signature) { return true }
}
return false
}Check your implementation
This public test fixture uses the sample secret whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx and timestamp 1793510400. Pass that timestamp as the verifier's test clock. The body below has no trailing newline. Do not use the sample secret for an endpoint.
{"object":"event","id":"evt_01jbqz5m3e6k8r9t2v4w6x8y0a","type":"ping","created_at":"2026-11-01T08:00:00Z","data":{}}
The expected hex signature is:
89950052be1fad58e3a1e4f0e3d702ddefe1a11ffcedd2be8c9e74a1bf82a664
Inspect and retry deliveries
Use GET /webhooks/{id}/deliveries for the latest 100 attempts. Each delivery reports its status, response status, elapsed milliseconds, error and next attempt time. The Developers page also shows this log and lets you retry a failed delivery.
A non-2xx answer or a ten-second timeout schedules retries after 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours and 24 hours. There are seven attempts in total. A private address is refused without making a request. The endpoint is disabled after 20 consecutive failed attempts spanning at least 24 hours. Fix it and use PATCH {"enabled":true} to re-enable it. Events and deliveries remain available for thirty days.
Send a test event
curl --fail-with-body --request POST 'https://api.todayfiles.com/v1/webhooks/wh_01jbqz5m3e6k8r9t2v4w6x8y0a/test' \
--header "Authorization: Bearer $TODAYFILES_API_KEY" \
--header 'Idempotency-Key: replace-with-a-unique-request-key'const response = await fetch("https://api.todayfiles.com/v1/webhooks/wh_01jbqz5m3e6k8r9t2v4w6x8y0a/test", {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.TODAYFILES_API_KEY,
"Idempotency-Key": "replace-with-a-unique-request-key",
},
});
if (!response.ok) throw new Error(await response.text());
const result = response.status === 204 ? null : await response.json();
console.log(result);import json
import os
import requests
response = requests.request(
"POST", "https://api.todayfiles.com/v1/webhooks/wh_01jbqz5m3e6k8r9t2v4w6x8y0a/test",
headers={
"Authorization": "Bearer " + os.environ["TODAYFILES_API_KEY"],
"Idempotency-Key": "replace-with-a-unique-request-key",
},
timeout=30,
)
response.raise_for_status()
print(response.json() if response.content else None)<?php
$client = curl_init("https://api.todayfiles.com/v1/webhooks/wh_01jbqz5m3e6k8r9t2v4w6x8y0a/test");
curl_setopt_array($client, [
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('TODAYFILES_API_KEY'),
'Idempotency-Key: replace-with-a-unique-request-key',
],
]);
$body = curl_exec($client);
if ($body === false) throw new RuntimeException(curl_error($client));
$status = curl_getinfo($client, CURLINFO_RESPONSE_CODE);
curl_close($client);
if ($status >= 400) throw new RuntimeException($body);
$result = $body === '' ? null : json_decode($body, true, 512, JSON_THROW_ON_ERROR);
print_r($result);package main
import (
"encoding/json"
"io"
"net/http"
"os"
"time"
)
func main() {
request, err := http.NewRequest("POST", "https://api.todayfiles.com/v1/webhooks/wh_01jbqz5m3e6k8r9t2v4w6x8y0a/test", nil)
if err != nil { panic(err) }
request.Header.Set("Authorization", "Bearer " + os.Getenv("TODAYFILES_API_KEY"))
request.Header.Set("Idempotency-Key", "replace-with-a-unique-request-key")
response, err := (&http.Client{Timeout: 30 * time.Second}).Do(request)
if err != nil { panic(err) }
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, 1 << 20))
if err != nil { panic(err) }
if response.StatusCode >= 400 { panic(string(body)) }
var result any
if len(body) > 0 {
if err := json.Unmarshal(body, &result); err != nil { panic(err) }
}
if err := json.NewEncoder(os.Stdout).Encode(result); err != nil { panic(err) }
}The call sends ping immediately and returns its delivery object. Use it after deploying an endpoint or changing its URL.
Rotate your secret
curl --fail-with-body --request POST 'https://api.todayfiles.com/v1/webhooks/wh_01jbqz5m3e6k8r9t2v4w6x8y0a/rotate-secret' \
--header "Authorization: Bearer $TODAYFILES_API_KEY" \
--header 'Idempotency-Key: replace-with-a-unique-request-key'const response = await fetch("https://api.todayfiles.com/v1/webhooks/wh_01jbqz5m3e6k8r9t2v4w6x8y0a/rotate-secret", {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.TODAYFILES_API_KEY,
"Idempotency-Key": "replace-with-a-unique-request-key",
},
});
if (!response.ok) throw new Error(await response.text());
const result = response.status === 204 ? null : await response.json();
console.log(result);import json
import os
import requests
response = requests.request(
"POST", "https://api.todayfiles.com/v1/webhooks/wh_01jbqz5m3e6k8r9t2v4w6x8y0a/rotate-secret",
headers={
"Authorization": "Bearer " + os.environ["TODAYFILES_API_KEY"],
"Idempotency-Key": "replace-with-a-unique-request-key",
},
timeout=30,
)
response.raise_for_status()
print(response.json() if response.content else None)<?php
$client = curl_init("https://api.todayfiles.com/v1/webhooks/wh_01jbqz5m3e6k8r9t2v4w6x8y0a/rotate-secret");
curl_setopt_array($client, [
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('TODAYFILES_API_KEY'),
'Idempotency-Key: replace-with-a-unique-request-key',
],
]);
$body = curl_exec($client);
if ($body === false) throw new RuntimeException(curl_error($client));
$status = curl_getinfo($client, CURLINFO_RESPONSE_CODE);
curl_close($client);
if ($status >= 400) throw new RuntimeException($body);
$result = $body === '' ? null : json_decode($body, true, 512, JSON_THROW_ON_ERROR);
print_r($result);package main
import (
"encoding/json"
"io"
"net/http"
"os"
"time"
)
func main() {
request, err := http.NewRequest("POST", "https://api.todayfiles.com/v1/webhooks/wh_01jbqz5m3e6k8r9t2v4w6x8y0a/rotate-secret", nil)
if err != nil { panic(err) }
request.Header.Set("Authorization", "Bearer " + os.Getenv("TODAYFILES_API_KEY"))
request.Header.Set("Idempotency-Key", "replace-with-a-unique-request-key")
response, err := (&http.Client{Timeout: 30 * time.Second}).Do(request)
if err != nil { panic(err) }
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, 1 << 20))
if err != nil { panic(err) }
if response.StatusCode >= 400 { panic(string(body)) }
var result any
if len(body) > 0 {
if err := json.Unmarshal(body, &result); err != nil { panic(err) }
}
if err := json.NewEncoder(os.Stdout).Encode(result); err != nil { panic(err) }
}Save the new secret before leaving the response. For the next 24 hours, requests carry a signature for the new secret and a signature for the old one. Update your receiver during that window, then retire the old secret.
Recover missed events
curl --fail-with-body --request GET 'https://api.todayfiles.com/v1/events' \
--header "Authorization: Bearer $TODAYFILES_API_KEY"const response = await fetch("https://api.todayfiles.com/v1/events", {
method: "GET",
headers: {
Authorization: "Bearer " + process.env.TODAYFILES_API_KEY,
},
});
if (!response.ok) throw new Error(await response.text());
const result = response.status === 204 ? null : await response.json();
console.log(result);import json
import os
import requests
response = requests.request(
"GET", "https://api.todayfiles.com/v1/events",
headers={
"Authorization": "Bearer " + os.environ["TODAYFILES_API_KEY"],
},
timeout=30,
)
response.raise_for_status()
print(response.json() if response.content else None)<?php
$client = curl_init("https://api.todayfiles.com/v1/events");
curl_setopt_array($client, [
CURLOPT_CUSTOMREQUEST => "GET",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('TODAYFILES_API_KEY'),
],
]);
$body = curl_exec($client);
if ($body === false) throw new RuntimeException(curl_error($client));
$status = curl_getinfo($client, CURLINFO_RESPONSE_CODE);
curl_close($client);
if ($status >= 400) throw new RuntimeException($body);
$result = $body === '' ? null : json_decode($body, true, 512, JSON_THROW_ON_ERROR);
print_r($result);package main
import (
"encoding/json"
"io"
"net/http"
"os"
"time"
)
func main() {
request, err := http.NewRequest("GET", "https://api.todayfiles.com/v1/events", nil)
if err != nil { panic(err) }
request.Header.Set("Authorization", "Bearer " + os.Getenv("TODAYFILES_API_KEY"))
response, err := (&http.Client{Timeout: 30 * time.Second}).Do(request)
if err != nil { panic(err) }
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, 1 << 20))
if err != nil { panic(err) }
if response.StatusCode >= 400 { panic(string(body)) }
var result any
if len(body) > 0 {
if err := json.Unmarshal(body, &result); err != nil { panic(err) }
}
if err := json.NewEncoder(os.Stdout).Encode(result); err != nil { panic(err) }
}Filter by type and pass the last event id as after to continue backwards through the last thirty days. The feed is scoped to your account. Deduplicate recovered events with the same event.id rule as live deliveries.