# 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.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:

```http
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:

<div class="flow" role="img" aria-label="Retried after 30 seconds, 2 minutes, 10 minutes, 1 hour and 6 hours, then given up">
<span>30 s</span><span>2 min</span><span>10 min</span><span>1 h</span><span>6 h</span><span>given up</span>
</div>

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.

```python
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:

```bash
# 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.

```python
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")
```

```typescript
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.
