Webhooks
Webhooks tell your server when a paper is read or approved, a script is marked or released, or something fails, so you don’t have to poll.
Create an endpoint
Section titled “Create an endpoint”Your endpoint is an HTTPS URL on your server that accepts POST requests with a JSON body. Register it with the API:
POST /v1/webhook_endpoints
{ "url": "https://example.com/aide/webhooks", "events": [ "paper.awaiting_review", "paper.ready", "paper.failed", "submission.graded", "submission.released", "submission.failed", "credits.balance_low", "credits.expiring" ], "description": "Production marking"}Response 201:
{ "id": "we_5Rt7Yh2Kp9", "object": "webhook_endpoint", "livemode": true, "url": "https://example.com/aide/webhooks", "events": [ "paper.awaiting_review", "paper.ready", "paper.failed", "submission.graded", "submission.released", "submission.failed", "credits.balance_low", "credits.expiring" ], "description": "Production marking", "status": "enabled", "secret": "whsec_...", "created_at": "2026-10-06T09:00:00Z"}The secret appears only in this response and when you rotate it. Store it like an API key: you need it to verify signatures.
| Endpoint | Purpose |
|---|---|
POST /v1/webhook_endpoints |
Create an endpoint. |
GET /v1/webhook_endpoints |
List your endpoints. |
GET /v1/webhook_endpoints/{id} |
Retrieve an endpoint. secret is null. |
PATCH /v1/webhook_endpoints/{id} |
Change url, events, description or status (enabled or disabled). Returns the endpoint. |
DELETE /v1/webhook_endpoints/{id} |
Delete an endpoint. Returns 204. |
POST /v1/webhook_endpoints/{id}/rotate_secret |
Returns the endpoint with a new secret. The old secret keeps working for 24 hours. Rotating again within those 24 hours ends the earlier secret at once: only the newest two sign. |
- You can have up to 5 endpoints per mode. An endpoint receives only events from its own mode.
eventslists the event types to send.["*"]sends every type, including types we add later.- An endpoint URL must use HTTPS and a public hostname.
What we send
Section titled “What we send”Each event is a POST to your URL with an Event as the body:
POST /aide/webhooks HTTP/1.1Content-Type: application/jsonUser-Agent: AIDE-AI-Webhooks/1AIDE-Signature: t=1791278500,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd{ "id": "evt_2Bn8Vc5Xz3", "object": "event", "type": "submission.released", "livemode": true, "created_at": "2026-10-06T09:21:40Z", "data": { "object": { "id": "sub_7Hk2Pz9QaN", "object": "submission", "status": "released" } }}data.object is the full object as it was when the event happened; it is shortened here. Call the API if you need its latest state.
Verify the signature
Section titled “Verify the signature”Every delivery is signed with your endpoint’s secret. Verify the signature before you trust the body, so nobody else can send you fake events.
The AIDE-Signature header has:
t: the Unix time, in seconds, at which we signed this delivery attempt. Every retry is signed again with a newt, sotis close to the time you receive the request even when the event itself is hours old.v1: one or more signatures. There is more than one only while a secret rotation is in progress.
To verify:
- Take the raw request body exactly as received, before any JSON parsing.
- Build the signed payload: the value of
t, a full stop (.), then the raw body. - Compute an HMAC with SHA-256 of the signed payload. Use your whole secret, including
whsec_, as the key. Hex-encode the result. - Compare it with each
v1value using a constant-time comparison. The request is genuine if any matches. - Reject the request if
tis more than 5 minutes away from your current time. This stops a captured request from being replayed. Never reject an event because itscreated_atis old: retries can arrive hours after the event.
Node.js (Express)
import crypto from 'node:crypto';import express from 'express';
const SECRET = process.env.AIDE_WEBHOOK_SECRET; // whsec_...const TOLERANCE_SECONDS = 300;
function isValidAideSignature(rawBody, header, secret) { const pairs = header.split(',').map((part) => part.trim().split('=')); const timestamp = pairs.find(([key]) => key === 't')?.[1]; const signatures = pairs.filter(([key]) => key === 'v1').map(([, value]) => value); if (!timestamp || !/^\d+$/.test(timestamp) || signatures.length === 0) return false; if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const expected = crypto .createHmac('sha256', secret) .update(`${timestamp}.`) .update(rawBody) .digest(); return signatures.some((signature) => { const received = Buffer.from(signature, 'hex'); return received.length === expected.length && crypto.timingSafeEqual(received, expected); });}
const app = express();
app.post('/aide/webhooks', express.raw({ type: 'application/json' }), async (req, res) => { if (!isValidAideSignature(req.body, req.get('AIDE-Signature') ?? '', SECRET)) { return res.status(400).send('Invalid signature'); } const event = JSON.parse(req.body.toString('utf8')); await saveEvent(event); // store it first, deduplicated by event.id res.sendStatus(200); // then acknowledge, and process it in a background job});Python (Flask)
import hashlibimport hmacimport osimport time
from flask import Flask, abort, request
SECRET = os.environ["AIDE_WEBHOOK_SECRET"].encode() # whsec_...TOLERANCE_SECONDS = 300
app = Flask(__name__)
def is_valid_aide_signature(raw_body: bytes, header: str, secret: bytes) -> bool: pairs = [part.strip().split("=", 1) for part in header.split(",") if "=" in part] timestamp = next((value for key, value in pairs if key == "t"), "") signatures = [value for key, value in pairs if key == "v1"] if not timestamp.isdigit() or not signatures: return False if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS: return False expected = hmac.new(secret, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected, signature) for signature in signatures)
@app.post("/aide/webhooks")def aide_webhook(): signature = request.headers.get("AIDE-Signature", "") if not is_valid_aide_signature(request.get_data(), signature, SECRET): abort(400) save_event(request.get_json()) # store it first, deduplicated by event["id"] return "", 200 # then acknowledge, and process it in a background jobPHP
<?php$secret = getenv('AIDE_WEBHOOK_SECRET'); // whsec_...$toleranceSeconds = 300;
$rawBody = file_get_contents('php://input');$header = $_SERVER['HTTP_AIDE_SIGNATURE'] ?? '';
$timestamp = '';$signatures = [];foreach (explode(',', $header) as $part) { $pair = explode('=', trim($part), 2); if (count($pair) !== 2) { continue; } if ($pair[0] === 't') { $timestamp = $pair[1]; } elseif ($pair[0] === 'v1') { $signatures[] = $pair[1]; }}
$isValid = false;if (ctype_digit($timestamp) && abs(time() - (int) $timestamp) <= $toleranceSeconds) { $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); foreach ($signatures as $signature) { if (hash_equals($expected, $signature)) { $isValid = true; break; } }}
if (!$isValid) { http_response_code(400); exit;}
$event = json_decode($rawBody, true);// Store $event first, deduplicated by $event['id'], then process it in a background job.http_response_code(200);Delivery and retries
Section titled “Delivery and retries”-
Success is any
2xxresponse within 10 seconds. Redirects are not followed and count as failures. -
Retries: after a failure we retry with growing delays, from 1 minute up to 12 hours, for 3 days. Each attempt is signed afresh.
-
At least once: the same event can arrive more than once. Record each
event.idyou process and skip repeats. -
No ordering guarantee: events can arrive out of order. Statuses only move forward:
- papers:
processing→awaiting_review→ready, orfailed; - submissions:
queued→processing→graded→released, orfailedorcanceled.
Ignore an event that would move your record backwards. If you’re unsure, fetch the object.
- papers:
-
Disabled endpoints: if every delivery to an endpoint fails for 5 days, we disable it and email your technical contact. Changing its
urlstarts the 5 days again. Re-enable it withPATCH. Re-enabling doesn’t resend the events you missed; fetch them fromGET /v1/events.
Good practice:
- Store each event, then return
2xxstraight away and do the work in a background job. Slow handlers cause timeouts and retries. - Subscribe only to the event types you handle.
- We don’t publish fixed IP addresses for webhooks; rely on the signature. If your firewall needs our addresses, contact us.
Event types
Section titled “Event types”| Type | Sent when | data.object |
|---|---|---|
paper.awaiting_review |
A paper’s questions, marks and answer keys have been read. Check its warnings, then approve it. | Paper |
paper.ready |
A paper was approved, through the API or a review link. You can now create submissions for it. | Paper |
paper.failed |
A paper couldn’t be processed. No credits were charged. | Paper |
submission.graded |
A submission has been marked. Its result is provisional until it is released. | Submission |
submission.released |
A submission’s result is final. Its result and report are available. | Submission |
submission.failed |
A submission couldn’t be marked. No credits were charged. | Submission |
credits.balance_low |
Your available live balance fell below your alert threshold, including when your credits expire. Sent once each time it crosses. | Balance |
credits.expiring |
Your credits’ validity ends in 7 days. | Account |
We may add event types; ignore types you don’t recognise.
On an automatic paper, submission.graded and submission.released are sent together. If you only act on final results, subscribe to submission.released, and to submission.graded only for scripts with no readable answers.