Webhooks
CTFFactory webhooks let your systems receive real-time notifications when key lifecycle events occur on the platform. Instead of polling the API, you register an endpoint URL and CTFFactory sends a signed JSON POST to it whenever a subscribed event fires.
Registering a Webhook Endpoint
From the dashboard:
- Open Webhooks from the main navigation.
- Under Register a webhook, enter your endpoint URL (must start with
http://orhttps://; use HTTPS in production). - Tick the events you want to receive. Leave every box unchecked to receive all events.
- Click Register webhook. CTFFactory generates a unique signing secret and shows it once β copy it immediately and store it securely. It is used to verify the signature on every delivery.
Registered endpoints appear in the Registered webhooks table, where each row can be deleted.
You can also manage endpoints from the REST API. Unlike the dashboard, the API expects you to supply the signing secret (16β128 characters):
POST /api/v1/webhooks
Authorization: Bearer ctff_...
Content-Type: application/json
{
"url": "https://your-app.example.com/hooks/ctffactory",
"events": ["ctf.deployed", "challenge.published"],
"secret": "whsec_your_own_shared_secret_value"
}
eventsmay be an empty list ([]) to subscribe to every event.- Registering requires the
ctf:adminscope; listing requiresctf:read.
List and delete endpoints:
GET /api/v1/webhooks # returns registered webhooks + available_events
DELETE /api/v1/webhooks/{id} # requires ctf:admin
Event Types
| Event | Trigger |
|---|---|
ctf.deployed |
A managed CTFd instance finished deploying and is reachable |
ctf.archived |
A managed CTF's final competition snapshot was archived |
ctf.deleted |
A managed CTF and its infrastructure were deleted |
challenge.published |
A challenge was published to CTFd |
challenge.unpublished |
A challenge was removed from CTFd |
deployment.deployed |
A standalone single-challenge deployment went live |
deployment.stopped |
A single-challenge deployment was stopped (its window elapsed) |
member.invited |
A competitor was invited to a CTF (or re-invited / self-enrolled) |
member.joined |
An invited competitor accepted their invitation and joined |
The authoritative list is always returned in the available_events field of GET /api/v1/webhooks. Subscribe only to the events your integration needs.
Payload Structure
Each delivery is a JSON POST to your endpoint. Every event shares the same envelope:
{
"event": "ctf.deployed",
"delivered_at": 1768137120,
"data": { }
}
eventβ the event name.delivered_atβ Unix timestamp (seconds) when the delivery was built.dataβ event-specific payload.
The body is serialized compactly with sorted keys, so the exact bytes are stable for signature verification.
The following headers accompany every delivery:
| Header | Value |
|---|---|
Content-Type |
application/json |
X-CTFFactory-Event |
the event name (e.g. ctf.deployed) |
X-CTFFactory-Signature |
sha256=<hex_digest> (see below) |
User-Agent |
CTFFactory-Webhook/1 |
Example data payloads
ctf.deployed
{
"data": {
"managed_ctf_id": "mctf_01JXABC456",
"title": "Spring Boot Camp CTF",
"url": "https://ctf.yourcompany.com/",
"is_public": true
}
}
challenge.published
{
"data": {
"managed_ctf_id": "mctf_01JXABC456",
"challenge_id": "mchal_01JXABC999",
"title": "Token Forge",
"category": "web",
"ctfd_challenge_id": 42
}
}
Verifying the HMAC-SHA256 Signature
Every delivery includes an X-CTFFactory-Signature header β an HMAC-SHA256 digest of the raw request body, keyed with your endpoint's signing secret. Always verify it before processing the payload.
X-CTFFactory-Signature: sha256=<hex_digest>
Python Verification Example
import hmac
import hashlib
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = "your_signing_secret_here" # shown once when you registered the endpoint
@app.route("/hooks/ctffactory", methods=["POST"])
def handle_webhook():
signature_header = request.headers.get("X-CTFFactory-Signature", "")
if not signature_header.startswith("sha256="):
abort(400, "Missing or malformed signature header")
received_sig = signature_header[len("sha256="):]
# Compute the expected signature over the RAW body bytes.
expected_sig = hmac.new(
key=WEBHOOK_SECRET.encode("utf-8"),
msg=request.get_data(), # raw bytes, not re-serialized JSON
digestmod=hashlib.sha256,
).hexdigest()
# Constant-time comparison to prevent timing attacks.
if not hmac.compare_digest(expected_sig, received_sig):
abort(401, "Signature verification failed")
event = request.json
if event["event"] == "ctf.deployed":
handle_ctf_deployed(event["data"])
return "", 200 # acknowledge receipt
def handle_ctf_deployed(data):
print(f"CTF deployed: {data['title']} at {data['url']}")
Important: Always compute the HMAC over the raw request body bytes, not over a re-serialized version of the parsed JSON β re-serialization can change the bytes and break verification.
Delivery and Retries
CTFFactory expects a 2xx HTTP status within 8 seconds to acknowledge a delivery. If the endpoint times out or returns a non-2xx response, the delivery is retried up to 2 times (3 attempts total) with a short linear backoff before being dropped.
Deliveries are dispatched in the background: a failing endpoint never blocks or breaks the lifecycle action that triggered the event. Make your handler idempotent β under retries the same event may be delivered more than once.