Score and rewrite jobs accept an optional webhookUrl on creation
(POST /v1/score-jobs, POST /v1/rewrite-jobs). When the job reaches a
terminal succeeded or failed state, DraftFilter posts a signed,
metadata-only notification to that URL. The webhook never carries customer
text or results; fetch results from the job endpoints (or a results-included
listing) after the notification arrives.
Requirements and behavior:
text
webhookUrl: https required, must be a public host
events: job.succeeded, job.failed (cancellations are not delivered)
retries: up to 3 attempts per event with backoff (1s, then 5s) on non-2xx or errors
timeout: about 10 seconds per attempt
delivery config is not part of the idempotent request identity
The webhook URL must resolve to a public address. URLs that target loopback,
private, link-local, or cloud-metadata addresses are rejected at request time,
and the host is re-resolved and re-checked at delivery time so a name cannot be
rebound to an internal address after acceptance.
Signing Secret
Each job gets its own signing secret. It is returned exactly once as
webhookSecret in the job creation response and is never shown again, stored,
or logged. Replaying the same idempotencyKey returns the same job and the
same secret.
The signature is an HMAC-SHA256 over "{t}." + raw request body, keyed with
the job's webhookSecret. Always verify against the raw body bytes before
parsing JSON, compare digests in constant time, and reject stale timestamps to
prevent replay:
python
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_draftfilter_webhook(secret: str, signature_header: str, body: bytes) -> bool:
parts = dict(part.split("=", 1) for part in signature_header.split(",") if "=" in part)
timestamp, provided = parts.get("t"), parts.get("v1")
if timestamp is None or provided is None or not timestamp.isdigit():
return False
if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
secret.encode("ascii"),
f"{timestamp}.".encode("ascii") + body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, provided)
Respond with any 2xx status to acknowledge delivery. Non-2xx responses and
connection errors are retried with backoff; after the final attempt the event
is dropped (the job itself is unaffected and remains fetchable).