Skip to content

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.

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.
  • events lists the event types to send. ["*"] sends every type, including types we add later.
  • An endpoint URL must use HTTPS and a public hostname.

Each event is a POST to your URL with an Event as the body:

POST /aide/webhooks HTTP/1.1
Content-Type: application/json
User-Agent: AIDE-AI-Webhooks/1
AIDE-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.

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 new t, so t is 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:

  1. Take the raw request body exactly as received, before any JSON parsing.
  2. Build the signed payload: the value of t, a full stop (.), then the raw body.
  3. Compute an HMAC with SHA-256 of the signed payload. Use your whole secret, including whsec_, as the key. Hex-encode the result.
  4. Compare it with each v1 value using a constant-time comparison. The request is genuine if any matches.
  5. Reject the request if t is more than 5 minutes away from your current time. This stops a captured request from being replayed. Never reject an event because its created_at is 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 hashlib
import hmac
import os
import 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 job

PHP

<?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);
  • Success is any 2xx response 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.id you process and skip repeats.

  • No ordering guarantee: events can arrive out of order. Statuses only move forward:

    • papers: processing → awaiting_review → ready, or failed;
    • submissions: queued → processing → graded → released, or failed or canceled.

    Ignore an event that would move your record backwards. If you’re unsure, fetch the object.

  • Disabled endpoints: if every delivery to an endpoint fails for 5 days, we disable it and email your technical contact. Changing its url starts the 5 days again. Re-enable it with PATCH. Re-enabling doesn’t resend the events you missed; fetch them from GET /v1/events.

Good practice:

  • Store each event, then return 2xx straight 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.
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.