Docs
Get an API key
Concepts›Webhooks

Webhooks

Have a finished video or song call your server, signed so you know it came from us.

View .md

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.generation.completed, music.generation.failed 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:

A delivery
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.
A receiver
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 secret
# 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:

  1. Take t and every v1 from X-Vultr-Signature.
  2. 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.
  3. Accept the delivery if the result matches any v1, and t is within a few minutes of now, so a captured delivery cannot be replayed.
X-Vultr-Signature
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")

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.