Webhooks
Receive signed POST callbacks when async jobs finish.
Pass webhook_url when calling POST /v1/jobs and we'll POST a signed event to that URL when the job terminates — success or failure.
Event shape #
POST /your-webhook HTTP/1.1
Content-Type: application/json
User-Agent: ChangeThisFile-Webhook/1.0
X-CTF-Event: job.completed
X-CTF-Signature: t=1735689600,v1=ab12cd34ef56…
{
"event": "job.completed",
"data": {
"job_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"status": "completed",
"result": {
"file_url": "https://r2.cloudflare.com/...signed...",
"size": 47281824,
"duration_ms": 27214,
"source_format": "mov",
"target_format": "mp4"
}
}
}| Header | Description |
|---|---|
X-CTF-Event | Event type. Currently job.completed, job.failed, or webhook.test. |
X-CTF-Signature | HMAC signature, format t={unix_ts},v1={hex} (see below). |
User-Agent | Always ChangeThisFile-Webhook/{version}. |
Verifying signatures #
The signature is HMAC-SHA-256(sha256(secret), "{timestamp}.{raw_body}"). The HMAC key is the SHA-256 hash of your webhook secret — matching the server, which only stores the hash, never the plaintext. ~25 lines of stdlib HMAC in any language. No dependency required.
import hashlib, hmac, time
from flask import Flask, request, abort
WH_SECRET = "whsec_..." # what you stored when you rotated
def verify_webhook(payload: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t, sig = parts.get("t"), parts.get("v1")
if not t or not sig:
return False
if abs(int(time.time()) - int(t)) > tolerance:
return False
secret_hash = hashlib.sha256(secret.encode()).hexdigest()
expected = hmac.new(
secret_hash.encode(),
f"{t}.".encode() + payload,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, sig)
app = Flask(__name__)
@app.post("/webhooks/ctf")
def receive():
if not verify_webhook(request.get_data(), request.headers.get("X-CTF-Signature", ""), WH_SECRET):
abort(401)
event = request.get_json()
# …handle event["event"], event["data"]
return "", 204
import crypto from 'node:crypto';
import express from 'express';
const WH_SECRET = 'whsec_...';
function verifyWebhook(payload, header, secret, tolerance = 300) {
const parts = Object.fromEntries(
header.split(',').filter(p => p.includes('=')).map(p => p.split('=', 2))
);
const { t, v1: sig } = parts;
if (!t || !sig) return false;
if (Math.abs(Date.now() / 1000 - parseInt(t)) > tolerance) return false;
const secretHash = crypto.createHash('sha256').update(secret).digest('hex');
const expected = crypto.createHmac('sha256', secretHash)
.update(`${t}.`).update(payload).digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
}
const app = express();
app.post('/webhooks/ctf', express.raw({ type: '*/*' }), (req, res) => {
if (!verifyWebhook(req.body, req.header('x-ctf-signature') ?? '', WH_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString());
// …handle event.event, event.data
res.sendStatus(204);
});
<?php
$WH_SECRET = 'whsec_...';
function verify_webhook(string $payload, string $header, string $secret, int $tolerance = 300): bool {
$parts = [];
foreach (explode(',', $header) as $p) {
if (strpos($p, '=') !== false) {
[$k, $v] = explode('=', $p, 2);
$parts[trim($k)] = trim($v);
}
}
$t = $parts['t'] ?? null;
$sig = $parts['v1'] ?? null;
if (!$t || !$sig) return false;
if (abs(time() - (int)$t) > $tolerance) return false;
$secretHash = hash('sha256', $secret);
$expected = hash_hmac('sha256', $t . '.' . $payload, $secretHash);
return hash_equals($expected, $sig);
}
$body = file_get_contents('php://input');
if (!verify_webhook($body, $_SERVER['HTTP_X_CTF_SIGNATURE'] ?? '', $WH_SECRET)) {
http_response_code(401); exit;
}
$event = json_decode($body, true);
// …handle $event['event'], $event['data']
http_response_code(204);
Replay protection #
Reject signatures whose timestamp drifts more than 5 minutes from the current wall clock (the tolerance argument in the snippets above). This prevents an attacker from replaying captured payloads forever.
If your servers run with significant clock skew, sync NTP — don't widen the tolerance window.
Rotating the secret #
curl -X POST https://changethisfile.com/v1/webhooks/secret \
-H "Authorization: Bearer ctf_sk_..."{
"secret": "whsec_NEW_SECRET_HERE",
"message": "Save this secret — it will not be shown again. The previous secret is now revoked."
}The previous secret is immediately revoked — there's no overlap window. Plan a brief deploy to switch over (or accept a few seconds of verification failures while your service redeploys).
Retry policy #
Failed deliveries (non-2xx response, network error, or 5-second timeout) are retried with exponential back-off:
| Attempt | Delay before retry |
|---|---|
| 1 | — (initial delivery) |
| 2 | 30 s |
| 3 | 5 min |
| 4 | 30 min |
| 5 | 2 h |
After 5 failed attempts the delivery is marked failed in the job record. Inspect via GET /v1/jobs/{job_id} — webhook_status and webhook_last_error reflect the latest delivery state.
Testing your endpoint #
curl -X POST https://changethisfile.com/v1/webhooks/test \
-H "Authorization: Bearer ctf_sk_..." \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/webhooks/ctf"}'Returns whether the test event was delivered (200 OK) and the response status from your server. Use this to debug signature verification before you wire up real jobs.