How it works
An administrator of your school sets an address under Organization → Webhook in Echelio. From then on, each test that ends is sent to that address as an HTTPS POST with a JSON body. It is part of the Organization plan.
- Every test of the school: code tests, quizzes and mixed tests. Practice is never sent.
- A full copy each time: every student’s results, not only what changed. Keep the latest copy.
- Signed as the Standard Webhooks specification describes, so you can check each request came from Echelio.
- No code and no answers: scores, participation, how each question ended, finish times and tab switches.
When it is sent
A test is sent about a minute after it ends, when its end time passes or a teacher stops it. It is sent again, as a new full copy, whenever a result changes after that:
- a submission made before the end finishes grading;
- a teacher grades an answer, or accepts an answer for every student who gave it;
- the AI grades an open answer;
- a student is removed from the class, or added back.
Changes within a minute of each other go in one copy, so a teacher grading 30 answers sends one or two copies, not thirty.
Each copy of a test has a sequence one higher than the last. While answers still wait for grading, session.final is false; a later copy brings the settled results.
Tests that ended before the address was set are not sent by themselves; an administrator can send one from its page. To try your side, use Send a test event under Organization → Webhook: it sends made-up results with "type": "test".
The request
Echelio sends a POST to your address with these headers:
content-type- always
application/json; user-agentEchelio-Webhooks/1;webhook-id- the delivery’s id, the same on every retry of it;
webhook-timestamp- when it was sent, in seconds since 1 January 1970 (UTC);
webhook-signaturev1,followed by the signature, explained below.
Answer with any 2xx status within 10 seconds. Anything else counts as a failure and is tried again, a redirect included: redirects are not followed. The address must use HTTPS and be reachable from the internet.
What it contains
A mixed test, sent while one answer still waits for grading:
{
"type": "session.results",
"sequence": 2,
"sentAt": "2026-11-03T10:15:02.000Z",
"organization": {
"id": "cmo1x8r2k0000",
"name": "Example School"
},
"class": {
"id": "cmc4b7t9p0002",
"name": "Web development · Group A"
},
"session": {
"id": "cms9d2v6h0004",
"name": "Week 3 test",
"format": "MIXED",
"startsAt": "2026-11-03T08:30:00.000Z",
"endsAt": "2026-11-03T10:00:00.000Z",
"closedAt": "2026-11-03T10:00:00.000Z",
"stoppedEarly": false,
"final": false,
"codeWeight": 60,
"resultsUrl": "https://echelio.com/teacher/sessions/cms9d2v6h0004",
"questions": [
{
"key": "q1",
"part": "QUIZ",
"points": 2
},
{
"key": "q2",
"part": "QUIZ",
"points": 3
},
{
"key": "c1",
"part": "CODE",
"title": "Sum of digits",
"language": "PYTHON",
"weight": 1
}
]
},
"students": [
{
"id": "cmu2f5k8n0006",
"name": "Ada Example",
"email": "[email protected]",
"removed": false,
"participation": "SUBMITTED",
"provisional": true,
"rank": 1,
"score": 82.5,
"quiz": {
"points": 2,
"totalPoints": 5,
"score": 40
},
"code": {
"score": 100,
"passed": 1
},
"completedAt": "2026-11-03T09:41:30.000Z",
"completionMinutes": 71.5,
"tabSwitches": 2,
"timeAwayMinutes": 0.8,
"questions": [
{
"key": "q1",
"outcome": "CORRECT"
},
{
"key": "q2",
"outcome": "TO_REVIEW"
},
{
"key": "c1",
"score": 100
}
]
},
{
"id": "cmu2f5k8n0007",
"name": "Bo Example",
"email": "[email protected]",
"removed": false,
"participation": "MISSED",
"provisional": false,
"rank": null,
"score": null,
"quiz": {
"points": null,
"totalPoints": 5,
"score": null
},
"code": {
"score": null,
"passed": null
},
"completedAt": null,
"completionMinutes": null,
"tabSwitches": 0,
"timeAwayMinutes": 0,
"questions": [
{
"key": "q1",
"outcome": "NOT_REACHED"
},
{
"key": "q2",
"outcome": "NOT_REACHED"
},
{
"key": "c1",
"score": null
}
]
}
]
}The top level
typesession.results, ortestfor a test event.sequence- Grows by one with each copy of the same test, from 1. A test event has 0.
sentAt- When this copy was built, in ISO 8601 (UTC).
organization- Your school:
idandname. class- The class the test was given to:
idandname.
session
id- The test’s id in Echelio, the same in every copy.
name- The test’s name, as the teacher wrote it.
formatCODE,QUIZorMIXED.startsAt, endsAt- The test’s scheduled start and end.
closedAt- When it ended: its end time, or when a teacher stopped it.
stoppedEarly- Whether a teacher stopped it before its end time.
final- True once no student’s result can still change by grading.
codeWeight- Mixed tests: the code part’s share of the score, in percent. Null otherwise.
resultsUrl- The test’s results page in Echelio, for teachers.
questions- The questions and problems, in the order students meet them.
session.questions[]
keyq1,q2… for quiz questions,c1,c2… for code problems. Students’ results use the same keys.partQUIZorCODE.points- Quiz questions: what the question is worth.
title, language, weight- Code problems: the problem’s title, its programming language, and its weight in the score.
students[]
id- The student’s id in Echelio.
name, email- The student’s name, and the email they sign in with.
removed- True when a teacher removed the student from the class after the test; their results stay.
participation- How the student took part (values below).
provisional- True while one of the student’s answers or submissions still waits for grading.
rank- The student’s place in the class; null without a score.
score- The student’s score on the whole test; null when they did not take it.
quiz- Quizzes and mixed tests:
points,totalPointsand the quiz part’sscore. Null in a code test. code- Code and mixed tests: the code part’s
scoreand how many problems werepassedin full. Null in a quiz. completedAt, completionMinutes- When the student finished, and how long after the start, in minutes; null if they never finished.
tabSwitches, timeAwayMinutes- How often the student left the test’s tab, and for how long in all; null when the test does not record tab switches.
questions- Each question’s
outcome(quiz) or bestscore(code), by key.
Scores are percentages from 0 to 100, rounded to one decimal, and times are in minutes, as in Echelio’s CSV export.
participation is one of SUBMITTED, TIMED_OUT, STOPPED, and MISSED.
A quiz question’s outcome is one of CORRECT, WRONG, TO_REVIEW, SKIPPED, and NOT_REACHED; TO_REVIEW waits for a teacher.
Checking the signature
Your administrator finds the signing secret under Organization → Webhook. It starts with whsec_; keep it on your server only. Check each request against its raw body, before parsing the JSON: the signature covers the exact bytes Echelio sent.
With a library
Standard Webhooks offers libraries for many languages, listed on standardwebhooks.com. In JavaScript:
import { Webhook } from "standardwebhooks";
const webhook = new Webhook(process.env.ECHELIO_WEBHOOK_SECRET);
// rawBody is the request's body as received, before any JSON parsing.
// verify() throws when the signature or the timestamp is wrong.
const payload = webhook.verify(rawBody, {
"webhook-id": request.headers["webhook-id"],
"webhook-timestamp": request.headers["webhook-timestamp"],
"webhook-signature": request.headers["webhook-signature"],
});Without a library
The signature is the HMAC-SHA256 of webhook-id, webhook-timestamp and the body, joined by dots, keyed with the secret’s bytes after whsec_ (base64), and written in base64. In Node.js:
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, headers, secret) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
// Refuse a request more than five minutes old, so an old one cannot be replayed.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();
// The header can hold several signatures, separated by spaces.
return headers["webhook-signature"].split(" ").some((part) => {
const [version, signature] = part.split(",");
const given = Buffer.from(signature ?? "", "base64");
return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected);
});
}When an administrator makes a new secret, the old one stops working at once; deliveries that fail meanwhile are tried again, signed with the new one.
Handling deliveries
- Answer first, then work. Store the body and answer 200, then process it, so slow work never makes a delivery fail.
- Keep the highest sequence. For each
session.id, keep the copy with the highestsequenceand ignore a lower one. - Skip what you have seen. A delivery can arrive twice, for example when your answer was lost; skip a
webhook-idyou have already handled. - Leave test events out. Requests with
"type": "test"carry made-up students. - Match students by
email, the address they sign in with, or by Echelio’sid.
When delivery fails
Echelio tries a delivery eight times over about two days: at once, then after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours. A newer copy of the same test takes the place of one still waiting to be tried again.
If every attempt fails, the school’s administrators get an email, at most one a day. The delivery log under Organization → Webhook shows each delivery, its attempts and your platform’s answer for 30 days. Fix your side, then press Send again.