Webhooks — Events Pushed to Your System
Instead of asking PrepareBuddy "anything new?" on a schedule, let PrepareBuddy tell you: when something you care about happens, we send it to a web address (URL) you choose. No coding? Use Zapier, Pabbly or Make as that address.
The events
| Event | Sent when | How fast |
|---|---|---|
result.final |
A result is final — a language test finished (or released after teacher review), a quiz submitted, assessment feedback released — and again if it is re-marked. | Seconds |
student.joined |
Someone becomes a student of your organization (added, onboarded, enrolled, from an LMS). | ≤ 5 min |
student.left |
Someone stops being a student of your organization: their membership is deactivated or removed and they have no current enrolment. | ≤ 5 min |
enrollment.created |
A student is enrolled in one of your courses. | ≤ 5 min |
enrollment.updated |
An enrolment changes — access extended or shortened, status changed. | ≤ 5 min |
enrollment.expiring |
An enrolment with access ends in 7 days or less (once per end date). | ≤ 5 min |
enrollment.ended |
An enrolment ends — expired, completed, dropped or suspended. | ≤ 5 min |
assignment.created |
A test, quiz or assessment is assigned to a student (in the app, by the API, or from an LMS). | ≤ 5 min |
assignment.overdue |
An assignment passes its due date without being finished (once). | ≤ 5 min |
ping |
You clicked Send test. | Seconds |
Events describe changes after you add the webhook — adding one does not replay your history. For history,
use the API (GET /results/, /students/, /assignments/).
You only ever receive your own organization's data: your students, your courses and assignments, and — for
results — exactly what GET /results/ would show you.
Set it up
- Organization dashboard → API Keys & Integrations → Manage webhooks.
- URL: your receiver's public
https://address. - Time zone for timestamps: e.g.
Asia/Kolkata,Europe/London— orUTC. - Send these events: tick what you want (all are ticked by default).
- Add webhook → copy the signing secret into your receiver.
- Send test → a
pingarrives within seconds; Recent deliveries shows your server's reply.
Up to 10 URLs per organization, each with its own events. Change events or time zone later under the URL (click N events · time zone).
What we send
Every event is an HTTPS POST with a JSON body:
POST https://your-system.example.com/preparebuddy/webhook
Content-Type: application/json
X-PrepareBuddy-Event: result.final
X-PrepareBuddy-Delivery: evt_4f0c9b2e7a1d3c5e8f60a1b2
X-PrepareBuddy-Signature: t=1790500000,v1=5c3f…e91a
{
"id": "evt_4f0c9b2e7a1d3c5e8f60a1b2",
"type": "result.final",
"created": "2026-09-27T15:45:04+05:30",
"organization": {"id": 12, "slug": "acme-prep", "name": "Acme Prep"},
"data": { … depends on the event, see below … }
}
id— unique per event. If you ever receive the sameidtwice (a retry), ignore the second.type— which event.data— the thing it is about. Forresult.finalandassignment.*it has its ownid: store by thatdata.id, so a re-marked result replaces the old row instead of adding a new one.
result.final
The same object as GET /results/:
"data": {
"id": "test:3f8b2c1e-6a0d-4c7e-9b1a-2d4e5f6a7b8c",
"kind": "test",
"student": {"user_id": 812, "email": "asha.rao@uni.example", "first_name": "Asha", "last_name": "Rao"},
"item": {"id": 57, "title": "IELTS Academic Mock 3", "type": "ielts", "type_label": "IELTS"},
"attempt": "assigned",
"score": 7.5, "max_score": 9, "percentage": 83.3, "score_label": "IELTS band 7.5 / 9",
"sections": {"listening": 8.0, "reading": 7.5, "writing": 6.5, "speaking": 7.0},
"started_at": "2026-09-27T13:32:11+05:30", "completed_at": "2026-09-27T15:45:03+05:30", "released_at": null,
"result_url": "https://www.preparebuddy.com/language-tests/session/3f8b2c1e-…/result/"
}
kind is test, quiz (score = percentage, out of 100) or assessment (rubric points out of the rubric total).
student.joined and student.left
The same object as GET /students/:
"data": {
"user_id": 812, "email": "asha.rao@uni.example", "first_name": "Asha", "last_name": "Rao",
"has_set_password": true, "joined_at": "2026-08-01T14:42:00+05:30", "last_login": null,
"membership": {"role": "member", "status": "active"},
"enrollments": [{"course_id": 31, "course_name": "IELTS Batch A", "course_type": "ielts",
"status": "active", "start_date": "2026-08-01", "end_date": "2026-10-31",
"grace_period_end": "2026-11-07", "has_access": true}]
}
enrollment.created, enrollment.updated, enrollment.expiring, enrollment.ended
"data": {
"student": {"user_id": 812, "email": "asha.rao@uni.example", "first_name": "Asha", "last_name": "Rao"},
"enrollment": {"enrollment_id": 4410, "course_id": 31, "course_name": "IELTS Batch A",
"course_type": "ielts", "status": "active", "start_date": "2026-08-01",
"end_date": "2027-01-31", "grace_period_end": "2027-02-07", "has_access": true},
"previous": {"end_date": "2026-10-31"}
}
enrollment.updatedaddsprevious: only the fields that changed, with their old values (above: access was extended from 31 Oct to 31 Jan).enrollment.expiringaddsdays_left(1–7).enrollment.endedaddsprevious_status(e.g."active");enrollment.statusis the new one.
assignment.created and assignment.overdue
The same object as GET /assignments/:
"data": {
"id": "test-assignment:1f2e9c4a-…", "kind": "test",
"student": {"user_id": 812, "email": "asha.rao@uni.example", "first_name": "Asha", "last_name": "Rao"},
"item": {"id": 57, "title": "IELTS Academic Mock 3", "type": "ielts"},
"status": "pending", "overdue": true,
"assigned_at": "2026-09-20T14:30:00+05:30", "due_date": "2026-09-27T23:59:59+05:30",
"completed_at": null, "result_id": null
}
When the student finishes, the result arrives as result.final with data.id equal to this result_id.
Verify the signature (recommended)
Anyone who learns your URL could post fake events to it. The signature proves an event came from
PrepareBuddy. X-PrepareBuddy-Signature looks like t=1790500000,v1=5c3f…:
- Split it into
t(a Unix time) andv1(a code). - Compute HMAC-SHA256 of the text
t+.+ the raw request body using your signing secret. - It must equal
v1, andtmust be within 5 minutes of now.
Use the raw body exactly as received — not the JSON after your framework parsed it.
PHP (most shared web hosting)
Save as preparebuddy-webhook.php on your site; use its address as the webhook URL.
<?php
$secret = 'whsec_PASTE_YOUR_SIGNING_SECRET'; // better: read it from an environment variable
$body = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_PREPAREBUDDY_SIGNATURE'] ?? '';
parse_str(str_replace(',', '&', $header), $sig); // gives $sig['t'] and $sig['v1']
if (empty($sig['t']) || empty($sig['v1']) || abs(time() - (int)$sig['t']) > 300) {
http_response_code(400); exit('stale or missing signature');
}
$expected = hash_hmac('sha256', $sig['t'] . '.' . $body, $secret);
if (!hash_equals($expected, $sig['v1'])) {
http_response_code(401); exit('bad signature');
}
$event = json_decode($body, true);
switch ($event['type']) {
case 'result.final':
$r = $event['data'];
// e.g. save to your database: INSERT … ON DUPLICATE KEY UPDATE by $r['id']
error_log("{$r['student']['email']} scored {$r['score']} / {$r['max_score']} on {$r['item']['title']}");
break;
case 'enrollment.expiring':
error_log("{$event['data']['student']['email']}: {$event['data']['days_left']} days left");
break;
}
http_response_code(200);
echo 'ok';
Python (Flask)
import hashlib, hmac, json, os, time
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["PREPAREBUDDY_WEBHOOK_SECRET"]
def verified(raw: bytes, header: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
if "t" not in parts or "v1" not in parts or abs(time.time() - int(parts["t"])) > 300:
return False
expected = hmac.new(SECRET.encode(), f"{parts['t']}.".encode() + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
@app.post("/preparebuddy/webhook")
def webhook():
raw = request.get_data() # the raw body
if not verified(raw, request.headers.get("X-PrepareBuddy-Signature", "")):
return "bad signature", 401
event = json.loads(raw)
if event["type"] == "result.final":
result = event["data"]
print(result["student"]["email"], result["score"], "/", result["max_score"])
return "", 200 # answer quickly; do slow work afterwards
Node.js (Express)
const crypto = require("crypto");
const express = require("express");
const app = express();
const SECRET = process.env.PREPAREBUDDY_WEBHOOK_SECRET;
function verified(raw, header) {
const parts = Object.fromEntries((header || "").split(",").map(p => p.split("=")));
if (!parts.t || !parts.v1 || Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = crypto.createHmac("sha256", SECRET)
.update(Buffer.concat([Buffer.from(parts.t + "."), raw])).digest("hex");
return expected.length === parts.v1.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
// express.raw keeps the body exactly as received — required for the signature
app.post("/preparebuddy/webhook", express.raw({ type: "application/json" }), (req, res) => {
if (!verified(req.body, req.get("X-PrepareBuddy-Signature"))) return res.status(401).send("bad signature");
const event = JSON.parse(req.body.toString("utf8"));
if (event.type === "result.final") console.log(event.data.student.email, event.data.score);
res.sendStatus(200);
});
app.listen(3000);
Answering, retries, switching off
- Answer with any 2xx status within 10 seconds. Do slow work after answering.
- A 5xx, 408, 429, redirect, timeout or connection error is retried after 1, 2, 4, 8, 16, 32 and then every 60 minutes — 12 tries over about 6 hours.
- Any other 4xx means "I don't want this one" and is not retried. Fix your receiver, then click Resend next to the delivery.
- After 50 failed attempts in a row the URL is switched off and the Webhooks page says why. Turn it back on once fixed; anything you missed is still available from the API.
- Redirects are not followed — use the final address. (This is why a Google Apps Script web app cannot receive webhooks directly; use Zapier/Pabbly/Make in between.)
- Only public
https://addresses are accepted.
Belt and braces: webhooks are fast, the API is complete. Many teams react to webhooks and run a
nightly GET /results/?cursor=… to catch anything missed during an outage of their own.
New signing secret
New secret on the Webhooks page replaces the secret immediately — update your receiver straight away.
Troubleshooting
| On the Webhooks page | Meaning | Fix |
|---|---|---|
| Sending… (attempt 3) | Your server did not answer 2xx; we are retrying. | Check your server's logs / uptime. |
| Failed — 401 | Your receiver rejected the signature. | Copy the current signing secret again; use the raw body. |
| Failed — 404 | The URL does not exist. | Fix the URL (and Resend). |
| Off — "Switched off after 50 failed attempts" | Too many failures in a row. | Fix the receiver, then Turn on. |
| Nothing arrives for students/enrolments | Those events are checked every 5 minutes, and describe changes after the webhook was added. | Wait a few minutes; make a change to test. |
