Webhook recipes
Two recipes: register_webhook.py creates a webhook for task events on a
project or an organization, pings it, and summarizes its recorded deliveries;
webhook_resource_monitoring.py is the receiving side — it runs a local HTTP
server, registers a webhook pointing at it, verifies each delivery’s signature,
and tallies the tasks created in the project as they arrive.
CVAT signs every delivery with the webhook secret: the X-Signature-256
header carries sha256=<HMAC-SHA256 of the request body>. A receiver that
recomputes and compares the signature (as webhook_resource_monitoring.py
does) can be sure the payload came from the server and not from someone who
merely knows the URL.
Register a webhook and inspect its deliveries
Creates a webhook scoped to a project (--project-id) or a whole organization
(--org), sends a test ping, then lists all recorded deliveries with
get_paginated_collection() and prints how many there are per HTTP status.
There is no high-level proxy for webhooks yet, so the recipe shows the
low-level client.api_client.webhooks_api.
| Flag | Required | Meaning |
|---|---|---|
--host |
yes | Server URL |
--token |
yes | Personal Access Token |
--project-id |
one of --project-id / --org |
Watch one project |
--org SLUG |
one of --project-id / --org |
Watch a whole organization |
--target-url |
yes | Where the server delivers the events |
--secret |
yes | Secret the server signs the deliveries with |
--events |
no | Events to subscribe to (default: create:task update:task delete:task) |
--cleanup |
no | Delete the created webhook at the end |
python register_webhook.py --host 'https://app.cvat.ai' --token '<your token>' \
--project-id 7 --target-url 'https://ci.example.com/cvat-events' --secret 'w3bh00k'
python register_webhook.py --host 'https://app.cvat.ai' --token '<your token>' \
--org 'annotators' --target-url 'https://ci.example.com/cvat-events' --secret 'w3bh00k'
The script
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
"""Register a project- or organization-scoped webhook, send a test ping, and
verify the ping shows up in the webhook's recorded deliveries.
The server signs every delivery with the webhook secret (HMAC-SHA256 in the
'X-Signature-256' header), so the receiver can verify the payload really came
from CVAT — see webhook_resource_monitoring.py for the receiving side.
Steps:
1. Create the webhook: scoped to a project (--project-id) or to a whole
organization (--org).
2. Send a ping — the server POSTs a test payload to --target-url and records
the delivery.
3. List all recorded deliveries and print how many there are per HTTP status.
4. Optionally delete the webhook (--cleanup).
Usage (run ``python register_webhook.py --help`` for the full list of options):
python register_webhook.py --host 'https://app.cvat.ai' --token '<your token>' \\
--project-id 7 --target-url 'https://ci.example.com/cvat-events' --secret 'w3bh00k'
python register_webhook.py --host 'https://app.cvat.ai' --token '<your token>' \\
--org 'annotators' --target-url 'https://ci.example.com/cvat-events' --secret 'w3bh00k'
"""
import argparse
import contextlib
from collections import Counter
from cvat_sdk import make_client, models
from cvat_sdk.core.helpers import get_paginated_collection
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
parser.add_argument(
"--token",
required=True,
help="Personal Access Token (CVAT UI: Profile -> Security)",
)
scope = parser.add_mutually_exclusive_group(required=True)
scope.add_argument("--project-id", type=int, help="id of an existing project, e.g. 7")
scope.add_argument("--org", metavar="SLUG", help="organization slug to watch as a whole")
parser.add_argument(
"--target-url",
required=True,
help="where the server delivers the events, e.g. 'https://ci.example.com/cvat-events'",
)
parser.add_argument(
"--secret", required=True, help="secret the server signs the deliveries with"
)
parser.add_argument(
"--events",
nargs="+",
default=["create:task", "update:task", "delete:task"],
help="events to subscribe to (default: %(default)s)",
)
parser.add_argument(
"--content-type",
default="application/json",
choices=["application/json", "application/x-www-form-urlencoded"],
help="payload content type the server sends (default: %(default)s)",
)
parser.add_argument(
"--cleanup", action="store_true", help="delete the created webhook at the end"
)
return parser.parse_args()
def main() -> None:
args = parse_args()
with make_client(args.host, access_token=args.token) as client:
webhooks_api = client.api_client.webhooks_api
if args.org is not None:
scope_context = client.organization_context(args.org)
spec = models.WebhookWriteRequest(
target_url=args.target_url,
type=models.WebhookType("organization"),
events=[models.EventsEnum(event) for event in args.events],
content_type=models.WebhookContentType(args.content_type),
secret=args.secret,
)
scope_label = f"organization {args.org!r}"
else:
scope_context = contextlib.nullcontext()
spec = models.WebhookWriteRequest(
target_url=args.target_url,
type=models.WebhookType("project"),
events=[models.EventsEnum(event) for event in args.events],
content_type=models.WebhookContentType(args.content_type),
secret=args.secret,
project_id=args.project_id,
)
scope_label = f"project {args.project_id}"
with scope_context:
webhook, _ = webhooks_api.create(spec)
print(f"Created webhook {webhook.id} for {scope_label} -> {webhook.target_url}")
print(f" events: {[str(event) for event in webhook.events]}")
delivery, _ = webhooks_api.create_ping(webhook.id)
print(f"Ping delivery: HTTP {delivery.status_code or 'failed'}")
# A busy webhook accumulates pages of deliveries; the list endpoint
# is paginated like every list in the API, so walk all the pages.
deliveries = get_paginated_collection(
webhooks_api.list_deliveries_endpoint, id=webhook.id
)
by_status = Counter(delivery.status_code for delivery in deliveries)
summary = ", ".join(f"{status} x{count}" for status, count in sorted(by_status.items()))
print(f"Webhook {webhook.id}: {len(deliveries)} deliveries, by status: {summary}")
if args.cleanup:
webhooks_api.destroy(webhook.id)
print(f"Deleted webhook {webhook.id}")
else:
print("Keeping the webhook; pass --cleanup to delete it")
if __name__ == "__main__":
main()
Watch new tasks appear live
Starts a local HTTP server on --port, registers a create:task webhook for
the project targeting --public-url (how the CVAT server reaches this machine
— a public IP, a DNS name, or a tunnel), and then, for every delivery: verifies
the signature, tallies the event, and prints the new task’s id and name. On
Ctrl-C — or after --max-events verified events — it prints the tallies and
how many deliveries were rejected for a bad signature.
| Flag | Required | Meaning |
|---|---|---|
--host |
yes | Server URL |
--token |
yes | Personal Access Token |
--project-id |
yes | Project whose new tasks to watch |
--public-url |
yes | URL under which the CVAT server can reach this machine |
--port |
no | Local port to listen on (default 8000) |
--secret |
yes | Secret the server signs the deliveries with |
--max-events |
no | Stop after this many verified events (default: run until Ctrl-C) |
--cleanup |
no | Delete the created webhook at the end |
python webhook_resource_monitoring.py --host 'https://app.cvat.ai' --token '<your token>' \
--project-id 7 --public-url 'https://my-tunnel.example.com/payload' \
--port 8000 --secret 'w3bh00k'
The script
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
"""Watch a project for newly created tasks via a webhook: register the webhook
pointing at this machine, receive the deliveries with a local HTTP server, and
tally each 'create:task' event.
The server signs every delivery with the webhook secret (HMAC-SHA256 of the
request body in the 'X-Signature-256' header). The receiver recomputes the
signature and rejects deliveries that don't match, so nobody who merely knows
the URL can inject fake events. --public-url is how the CVAT server reaches
this machine (a public IP, a DNS name, or a tunnel), while --port is where the
receiver listens locally.
Steps:
1. Start an HTTP server on --port.
2. Register a webhook for the project's 'create:task' events, targeting
--public-url.
3. For every delivery: verify the signature, then tally the event.
4. On Ctrl-C (or after --max-events deliveries), print the tallies.
5. Optionally delete the webhook (--cleanup).
Usage (run ``python webhook_resource_monitoring.py --help`` for the full list of options):
python webhook_resource_monitoring.py --host 'https://app.cvat.ai' --token '<your token>' \\
--project-id 7 --public-url 'https://my-tunnel.example.com/payload' \\
--port 8000 --secret 'w3bh00k'
"""
import argparse
import hashlib
import hmac
import json
import urllib.parse
from collections import Counter
from http.server import BaseHTTPRequestHandler, HTTPServer
from cvat_sdk import make_client, models
MONITORING_EVENTS = ["create:task"]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
parser.add_argument(
"--token",
required=True,
help="Personal Access Token (CVAT UI: Profile -> Security)",
)
parser.add_argument(
"--project-id", type=int, required=True, help="id of an existing project, e.g. 7"
)
parser.add_argument(
"--public-url",
required=True,
help="URL under which the CVAT server can reach this machine, "
"e.g. 'https://my-tunnel.example.com/payload'",
)
parser.add_argument(
"--port", type=int, default=8000, help="local port to listen on (default: %(default)s)"
)
parser.add_argument(
"--secret", required=True, help="secret the server signs the deliveries with"
)
parser.add_argument(
"--content-type",
default="application/json",
choices=["application/json", "application/x-www-form-urlencoded"],
help="payload content type the server sends (default: %(default)s)",
)
parser.add_argument(
"--max-events",
type=int,
help="stop after this many verified events (default: run until Ctrl-C)",
)
parser.add_argument(
"--cleanup", action="store_true", help="delete the created webhook at the end"
)
return parser.parse_args()
class DeliveryHandler(BaseHTTPRequestHandler):
"""One CVAT delivery per request: verify the signature, tally the event."""
# Set on the subclass by make_handler()
secret: bytes
event_counter: Counter
rejected: int = 0
def log_message(self, format: str, *args) -> None: # pylint: disable=redefined-builtin
pass # the tallies below replace the default per-request log line
def do_POST(self) -> None:
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
expected = "sha256=" + hmac.new(type(self).secret, body, hashlib.sha256).hexdigest()
provided = self.headers.get("X-Signature-256", "")
if not hmac.compare_digest(expected, provided):
type(self).rejected += 1
self.send_response(403)
self.end_headers()
return
self.send_response(200)
self.end_headers()
# For application/x-www-form-urlencoded, CVAT sends the JSON body under
# the 'payload' form field; for application/json, the body is the JSON.
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip()
if content_type == "application/x-www-form-urlencoded":
form = urllib.parse.parse_qs(body.decode("utf-8"))
payload = json.loads(form["payload"][0])
else:
payload = json.loads(body)
event_type = payload["event"]
if event_type == "ping":
print("Ping from the server", flush=True)
return
type(self).event_counter[event_type] += 1
if event_type == "create:task":
task = payload["task"]
print(f" new task {task['id']}: {task.get('name')!r}", flush=True)
def make_handler(secret: str) -> type:
return type(
"Handler",
(DeliveryHandler,),
{"secret": secret.encode(), "event_counter": Counter()},
)
def summarize(counter: Counter) -> str:
return ", ".join(f"{key} x{count}" for key, count in sorted(counter.items())) or "-"
def main() -> None:
args = parse_args()
handler = make_handler(args.secret)
with make_client(args.host, access_token=args.token) as client:
webhooks_api = client.api_client.webhooks_api
with HTTPServer(("", args.port), handler) as receiver:
webhook, _ = webhooks_api.create(
models.WebhookWriteRequest(
target_url=args.public_url,
type=models.WebhookType("project"),
events=[models.EventsEnum(event) for event in MONITORING_EVENTS],
content_type=models.WebhookContentType(args.content_type),
secret=args.secret,
project_id=args.project_id,
)
)
print(f"Created webhook {webhook.id} -> {webhook.target_url}", flush=True)
print(f"Listening on port {args.port}; press Ctrl-C to stop", flush=True)
try:
while (
args.max_events is None or sum(handler.event_counter.values()) < args.max_events
):
receiver.handle_request()
except KeyboardInterrupt:
pass
print(
f"Received {sum(handler.event_counter.values())} events: "
f"{summarize(handler.event_counter)}"
)
print(f"Rejected {handler.rejected} deliveries with a bad signature")
if args.cleanup:
webhooks_api.destroy(webhook.id)
print(f"Deleted webhook {webhook.id}")
else:
print("Keeping the webhook; pass --cleanup to delete it")
if __name__ == "__main__":
main()
Other SDK options:
| SDK method / parameter | What it adds |
|---|---|
webhooks_api.list(project_id=, target_url=, type=, ...) |
Filter the webhook list server-side. |
webhooks_api.retrieve_events() |
The full list of event names a webhook can subscribe to. |
webhooks_api.create_deliveries_redelivery(id, delivery_id) |
Re-send a failed delivery. |
webhooks_api.partial_update(id, patched_webhook_write_request=...) |
Change a webhook’s target, events, or active state in place. |
WebhookWriteRequest(..., is_active=False) |
Create a webhook disabled, to be enabled later. |
WebhookWriteRequest(..., enable_ssl=False) |
Skip TLS certificate verification for self-signed receivers. |
Notes:
- Webhook payloads carry the event name (e.g.
update:task), the serialized resource, thesender, and — for updates —before_update/changeswith the old field values. - An organization webhook lives in the organization’s scope, so every call
about it must be made in that organization’s context
(
client.organization_context(slug)). - The delivery list is paginated like every list endpoint;
get_paginated_collection()walks all the pages. - Full recipes:
register_webhook.py,webhook_resource_monitoring.py.