Webhooks
Have a finished video or song call your server, signed so you know it came from us.
Send callback_url with POST /v1/videos or POST /v1/music, and the API calls it once the job reaches a final state, so there is nothing to poll. The URL must be https on a public host: a private address is refused with a 400, and a name that resolves to one is never delivered to.
Events
Each event is named for its job's kind and how it ended: video., music. and so on.
| Ending | When |
|---|---|
completed |
The outputs are ready to download. |
failed |
The render failed. data.error and data.error_code say why. |
cancelled |
The job was deleted before it finished. |
expired |
The outputs passed expires_at and were deleted. |
A delivery
Each delivery is a POST of the event, with the job in data as a plain HTTP client reads it: pending rather than queued, and error as a string, whichever SDK created the job. Trimmed:
POST /hooks/video HTTP/1.1
Content-Type: application/json
User-Agent: Vultr-Inference-Webhooks/1.0
X-Vultr-Signature: t=1790000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
X-Vultr-Idempotency-Key: video_0123456789abcdef01234567-completed
{"type": "video.generation.completed", "created_at": 1790000000, "data": {"id": "video_0123456789abcdef01234567", "status": "completed", "unsigned_urls": ["https://api.vultrinference.com/v1/videos/video_0123456789abcdef01234567/content?index=0"]}}Answer, and retries
Answer any 2xx within 10 seconds. Anything else, a redirect included, and the delivery is sent again, up to five more times:
Every attempt carries the same body and idempotency key, with a fresh signature time.
- A delivery can arrive more than once: keep the idempotency keys you have handled, and ignore a repeat.
- Two events of one job can arrive out of order: trust the job's
status, not the order of arrival. - Answer first, then do the slow work.
import hashlib
import hmac
import json
import os
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["VULTR_WEBHOOK_SECRET"] # whsec_..., from GET /v1/webhooks/secret
handled = set() # keep these in your database: a delivery can arrive more than once
def verified(header: str, body: bytes) -> bool:
pairs = [part.split("=", 1) for part in header.split(",")]
timestamp = next((value for key, value in pairs if key == "t"), "0")
if abs(time.time() - int(timestamp)) > 300:
return False
expected = hmac.new(SECRET.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, value) for key, value in pairs if key == "v1")
class Hook(BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers["Content-Length"]))
if not verified(self.headers.get("X-Vultr-Signature", ""), body):
self.send_response(400)
self.end_headers()
return
# Answer within 10 seconds, or the delivery is sent again: do slow work after this
self.send_response(204)
self.end_headers()
key = self.headers["X-Vultr-Idempotency-Key"]
if key in handled:
return
handled.add(key)
event = json.loads(body)
job = event["data"]
if event["type"].endswith(".completed"):
print(job["id"], "is ready:", job["unsigned_urls"][0])
else:
print(job["id"], "ended", job["status"], job.get("error"))
if __name__ == "__main__":
HTTPServer(("", int(os.environ.get("PORT", "8000"))), Hook).serve_forever()Verify the signature
Deliveries are signed with your subscription's webhook secret. It is not your API key, so the server that receives webhooks never holds a key that can spend. GET /v1/webhooks/secret makes it on its first call:
# Your webhook signing secret (whsec_...), made on the first call
curl https://api.vultrinference.com/v1/webhooks/secret \
-H "Authorization: Bearer $VULTR_INFERENCE_API_KEY"To check a delivery:
- Take
tand everyv1fromX-Vultr-Signature. - Compute the hex HMAC-SHA256 of
t, a dot and the raw body, keyed with the whole secret,whsec_included. Use the body's bytes as they arrived, before any parsing. - Accept the delivery if the result matches any
v1, andtis within a few minutes of now, so a captured delivery cannot be replayed.
import hashlib
import hmac
import time
# secret: from GET /v1/webhooks/secret (whsec_...); header: the X-Vultr-Signature value; body: the raw request body
def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
pairs = [part.split("=", 1) for part in header.split(",")]
timestamp = next(value for key, value in pairs if key == "t")
if abs(time.time() - int(timestamp)) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
# While a secret is being rotated, the old and the new one both sign: two v1= values
return any(hmac.compare_digest(expected, value) for key, value in pairs if key == "v1")import { createHmac, timingSafeEqual } from "node:crypto";
// secret: from GET /v1/webhooks/secret (whsec_...); header: the X-Vultr-Signature value; body: the raw request body
export function verify(secret: string, header: string, body: Buffer, tolerance = 300): boolean {
const pairs = header.split(",").map((part) => part.split("=", 2) as [string, string]);
const timestamp = pairs.find(([key]) => key === "t")?.[1];
if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > tolerance) {
return false;
}
const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(body).digest();
// While a secret is being rotated, the old and the new one both sign: two v1= values
return pairs
.filter(([key]) => key === "v1")
.some(([, value]) => {
const given = Buffer.from(value, "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
});
}Rotate the secret
POST /v1/webhooks/secret/rotate makes a new secret and keeps the old one signing beside it for 24 hours, so each delivery carries two v1 values. Deploy the new secret within the day and nothing is missed. Rotating again within those 24 hours retires the oldest secret at once.
Endpoints