Receive webhooks
GPT-Video can call an address of your app when a job or an export ends. This guide adds a webhook in the app, checks the signature of each event on your server, and reads the export when one is done. Webhooks lists every event and the fields it carries.
1. Add the address
In the GPT-Video app, open the Developers page, at https://www.gpt-video.com/app/developers, and add the address of your receiver under Webhooks. The address must be reachable from the internet, and addresses on a private network are refused.
Copy the secret of the webhook. It starts with whsec_. The receivers below read it from the GPT_VIDEO_WEBHOOK_SECRET environment variable, and they read your API key from GPT_VIDEO_API_KEY, as Authentication explains.
2. Check the signature
Each event comes with the headers webhook-id, webhook-timestamp and webhook-signature. The signature is v1, followed by the base64 HMAC-SHA256 of the id, the timestamp and the raw body joined by dots. The key is the part of the secret after whsec_, decoded from base64.
Check the signature on the raw body, before you parse the JSON. A body that was parsed and written again has different bytes, and a real event would then fail the check. Refuse an event whose timestamp is more than five minutes away from your clock. While a secret is being rotated, the header carries two signatures separated by a space, and either one is enough.
Any library for Standard Webhooks does this check. The receivers below do it with the standard library of Node and of Python.
3. Answer first, then do the work
Answer with a 2xx status as soon as the signature checks out. Do the slow work after the answer.
The same event can arrive more than once. Every attempt carries the same webhook-id, so keep the ids you handled and skip one you have seen. The receivers below keep them in memory. A real app keeps them in its database, so they survive a restart.
4. Read the state
An event carries ids, and never a file or a download link. Read the current state with get_job (GET /v1/jobs/{jobId}) or get_export (GET /v1/exports/{exportId}). The receivers below read the export after export.completed and print its downloadUrl. The link works without an API key and expires soon, so download the file right away.
The receiver
The receiver listens on port 3000. The TypeScript has no type annotations, so it also runs as JavaScript. Save it as receiver.mjs and run it with Node 20 or later. The Python version needs the requests package and Python 3.9 or later.
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
// The secret of the webhook, as the Developers page shows it. The key is the base64 after whsec_.
const key = Buffer.from((process.env.GPT_VIDEO_WEBHOOK_SECRET ?? "").replace(/^whsec_/, ""), "base64");
const handled = new Set();
createServer((req, res) => {
let body = "";
req.setEncoding("utf8");
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", async () => {
// Check the signature on the raw body, before parsing it.
const id = String(req.headers["webhook-id"] ?? "");
const timestamp = String(req.headers["webhook-timestamp"] ?? "");
const expected = Buffer.from(createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest("base64"));
const signed = String(req.headers["webhook-signature"] ?? "")
.split(" ")
.some((part) => {
const given = Buffer.from(part.slice(3));
return part.startsWith("v1,") && given.length === expected.length && timingSafeEqual(given, expected);
});
const recent = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
if (!signed || !recent) {
res.writeHead(400).end();
return;
}
// Answer first, then do the work. A retry of this event has the same webhook-id.
res.writeHead(200).end();
if (handled.has(id)) return;
handled.add(id);
const event = JSON.parse(body);
if (event.type === "export.completed") {
const answer = await fetch(`https://api.gpt-video.com/v1/exports/${event.data.exportId}`, {
headers: { Authorization: `Bearer ${process.env.GPT_VIDEO_API_KEY}` },
});
const done = (await answer.json()).export;
console.log(`Export ${done.id} is ${done.status}. Download it now from ${done.downloadUrl}`);
}
});
}).listen(3000);import base64
import hashlib
import hmac
import json
import os
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
import requests
# The secret of the webhook, as the Developers page shows it. The key is the base64 after whsec_.
KEY = base64.b64decode(os.environ["GPT_VIDEO_WEBHOOK_SECRET"].removeprefix("whsec_"))
handled = set()
class Receiver(BaseHTTPRequestHandler):
def do_POST(self):
# Check the signature on the raw body, before parsing it.
body = self.rfile.read(int(self.headers.get("Content-Length", "0")))
event_id = self.headers.get("webhook-id", "")
timestamp = self.headers.get("webhook-timestamp", "")
expected = base64.b64encode(
hmac.new(KEY, f"{event_id}.{timestamp}.".encode() + body, hashlib.sha256).digest()
).decode()
signed = any(
part.startswith("v1,") and hmac.compare_digest(part[3:].encode(), expected.encode())
for part in self.headers.get("webhook-signature", "").split(" ")
)
recent = timestamp.isdigit() and abs(time.time() - int(timestamp)) <= 300
if not (signed and recent):
self.send_response(400)
self.send_header("Content-Length", "0")
self.end_headers()
return
# Answer first, then do the work. A retry of this event has the same webhook-id.
self.send_response(200)
self.send_header("Content-Length", "0")
self.end_headers()
if event_id in handled:
return
handled.add(event_id)
event = json.loads(body)
if event["type"] == "export.completed":
answer = requests.get(
f"https://api.gpt-video.com/v1/exports/{event['data']['exportId']}",
headers={"Authorization": f"Bearer {os.environ['GPT_VIDEO_API_KEY']}"},
)
done = answer.json()["export"]
print(f"Export {done['id']} is {done['status']}. Download it now from {done['downloadUrl']}")
HTTPServer(("", 3000), Receiver).serve_forever()Send a test event
Start the receiver, make it reachable from the internet, and send a test event from the Developers page. The test event has the type webhook.test, so the receiver answers it and does nothing else. When the signature check fails, the receiver answers 400. Check that the secret is the one of this webhook, and that nothing between the internet and your receiver changes the body.