Inbound
POST /api/webhooks/inbound accepts an API key with judgments:run. The body is the same { packId, ticket } as Judge a ticket. The pack must be published.
Idempotency-Key is required. It is the delivery id. The same key returns the existing event and does not start a second judgment.
The response is 202:
GET /api/evaluations.
Optional signature
X-Judged-Signature is optional on the inbound request. When you send it, it is checked against the saved completion secret: sha256= plus HMAC-SHA256 of the raw body. A mismatch, or a signature with no saved endpoint, is 401 with invalid signature. Omit the header to skip the check.
Outbound completion
Each user has one HTTPS endpoint. Save it withpacks:write:
201 returns { id, url, secret }. The secret is shown once. Saving again replaces the endpoint and returns a new secret. GET /api/webhook-endpoints lists { id, url } and does not include the secret. DELETE /api/webhook-endpoints/{id} returns 204.
The URL is public https with no userinfo.
The POST you receive
The body is the evaluation id and the result. It has no ticket.X-Judged-Signature is required. Value: sha256= plus HMAC-SHA256 of the raw body. Compare with a constant-time check against the bytes you received, before you parse JSON.
2xx. Redirects are not followed.
GET /api/webhook-deliveries returns up to 50 rows, newest first: status is pending, retrying, delivered, or failed, plus attemptCount, lastStatusCode, and lastError.
In the app, the delivery log labels the event evaluation.completed. That name is not a field on the body.