API

Webhooks

Version 2026-11-01OpenAPI 3.1Download openapi.json

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
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"}'
Node
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);
Python
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
<?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);
Go
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.

curl
# 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)
'
Node
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.
Python
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
<?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().
Go
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.

json
{"object":"event","id":"evt_01jbqz5m3e6k8r9t2v4w6x8y0a","type":"ping","created_at":"2026-11-01T08:00:00Z","data":{}}

The expected hex signature is:

text
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
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'
Node
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);
Python
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
<?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);
Go
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
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'
Node
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);
Python
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
<?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);
Go
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
curl --fail-with-body --request GET 'https://api.todayfiles.com/v1/events' \
  --header "Authorization: Bearer $TODAYFILES_API_KEY"
Node
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);
Python
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
<?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);
Go
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.

What's next

Read the error codes and choose safe retry behaviour.