Install our app for a better experience!

Webhooks — Results Pushed to Your System

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

  1. Organization dashboard → API Keys & Integrations → Manage webhooks.
  2. URL: your receiver's public https:// address.
  3. Time zone for timestamps: e.g. Asia/Kolkata, Europe/London — or UTC.
  4. Send these events: tick what you want (all are ticked by default).
  5. Add webhook → copy the signing secret into your receiver.
  6. Send test → a ping arrives 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 same id twice (a retry), ignore the second.
  • type — which event.
  • data — the thing it is about. For result.final and assignment.* it has its own id: store by that data.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.updated adds previous: only the fields that changed, with their old values (above: access was extended from 31 Oct to 31 Jan).
  • enrollment.expiring adds days_left (1–7).
  • enrollment.ended adds previous_status (e.g. "active"); enrollment.status is 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.

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…:

  1. Split it into t (a Unix time) and v1 (a code).
  2. Compute HMAC-SHA256 of the text t + . + the raw request body using your signing secret.
  3. It must equal v1, and t must 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.