Skip to content
GurukulLiveDevelopers
Build

Webhooks

GurukulLive POSTs signed JSON to your server when something happens: classes start and end, people join and leave, recordings and attendance are ready.

Manage endpoints in Settings → Webhooks (send test events, inspect deliveries, redeliver, rotate secrets) or with the API key scopes webhooks:read / webhooks:write. The full list of event types is in the event catalog.

GurukulLive was previously called VMeet. The header names (VMeet-Signature, VMeet-Webhook-Id, VMeet-Webhook-Timestamp) keep that prefix, so existing receivers keep working.

GurukulLive sends an HTTP POST to your server whenever something happens in your organization: a class starts, a student joins, a recording is ready, and so on. This guide covers how to register an endpoint, verify that a request really came from GurukulLive, handle retries and duplicates, and what each event looks like.

Architecture contract: DEVELOPER_PLATFORM.md §3. TypeScript types for every payload: @vmeet/types (WebhookEvent, WebhookEventDataMap, …).


1. Registering an endpoint

Endpoints are managed by an organization owner/admin in Settings → Developers → Webhooks, or through the API:

MethodSigned-in admin (/api/v1/orgs/:orgId/webhooks…)API key (/api/v1/public/webhooks…, scope webhooks:read for GET, webhooks:write otherwise; write implies read)
List / createGET / POST /orgs/:orgId/webhooksGET / POST /public/webhooks
Read / update / deleteGET / PATCH / DELETE /orgs/:orgId/webhooks/:idGET / PATCH / DELETE /public/webhooks/:id
Rotate secretPOST /orgs/:orgId/webhooks/:id/rotate-secretPOST /public/webhooks/:id/rotate-secret
Send a test eventPOST /orgs/:orgId/webhooks/:id/testPOST /public/webhooks/:id/test
Delivery log (30 days)GET /orgs/:orgId/webhooks/:id/deliveries?limit&cursor&status&eventTypeGET /public/webhooks/:id/deliveries?limit&cursor&status&eventType
Delivery detail (with payload)GET /orgs/:orgId/webhooks/deliveries/:deliveryIdGET /public/webhooks/deliveries/:deliveryId
RedeliverPOST /orgs/:orgId/webhooks/deliveries/:deliveryId/redeliverPOST /public/webhooks/deliveries/:deliveryId/redeliver
Event catalog ({ "wildcard": "*", "types": [...] })GET /orgs/:orgId/webhooks/event-typesGET /public/webhooks/event-types
http
POST /api/v1/orgs/org_123/webhooks
Content-Type: application/json

{ "url": "https://lms.example.com/vmeet/webhooks", "description": "LMS sync", "events": ["meeting.started", "meeting.ended", "participant.joined"] }
json
{ "data": { "id": "cm2…", "url": "https://lms.example.com/vmeet/webhooks", "events": ["meeting.started", "meeting.ended", "participant.joined"],
            "status": "ACTIVE", "secret": "whsec_Q2hhbmdlTWUtVGhpcy1Jcy1Ob3QtQS1SZWFsLVNlY3JldA", … } }
  • events lists the event types to receive; ["*"] subscribes to every current and future type.
  • The secret is shown only once (on create and on rotate). Store it in your secret manager. GurukulLive keeps it encrypted at rest (AES-256-GCM).
  • URLs must be public https:// addresses. Hosts that resolve to private, loopback, link-local, CGNAT or cloud metadata addresses (IPv4 or IPv6) are refused, both when you save the endpoint and again on every delivery.
  • At most 20 endpoints per organization. Disabled endpoints count toward the limit — delete the ones you no longer use; creating a 21st returns 409 limit_reached.
  • Webhooks are a plan feature (webhooks, included in Business and Enterprise). Without it every /orgs/:orgId/webhooks… route answers 402 plan_feature_unavailable; on the API-key surface create, update, rotate-secret, test and redeliver answer 402, while list, read, delete, the delivery log and the event catalog keep working so you can clean up after a downgrade. Events are not queued for an organization whose plan lacks the feature — except, for now, the ones emitted by the background worker (transcript.ready, insights.ready and the plan-cap meeting.ended), which don't check the plan yet (see KNOWN_GAPS.md).

2. What a delivery looks like

http
POST /vmeet/webhooks HTTP/1.1
Host: lms.example.com
Content-Type: application/json
User-Agent: VMeet-Webhooks/1.0
VMeet-Webhook-Id: evt_01JA7W5Q3Y8V6M2K4N0R9T1XZC
VMeet-Webhook-Timestamp: 1760436000
VMeet-Signature: v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

{"id":"evt_01JA7W5Q3Y8V6M2K4N0R9T1XZC","type":"meeting.started","createdAt":"2026-10-14T10:00:00.000Z","organizationId":"org_123","data":{…}}
HeaderMeaning
VMeet-Webhook-IdThe event id (evt_<ulid>). Same value as id in the body, identical across retries and redeliveries.
VMeet-Webhook-TimestampUnix seconds when this attempt was signed.
VMeet-Signaturev1=<hex HMAC-SHA256(secret, "<timestamp>.<raw body>")>. During a secret rotation there are two values: v1=<new>,v1=<old>.
User-AgentVMeet-Webhooks/1.0

The header names and the User-Agent keep the VMeet prefix of the product's former name (GurukulLive was called VMeet). They are part of the contract and will not change.

Body fields: id, type, createdAt (when the event happened, ISO-8601 UTC), organizationId, data (see §6).

Respond with any 2xx within 10 seconds. Do the real work asynchronously (queue it) and return 200 immediately. Redirects are not followed — a 3xx counts as a failure. Only the first 1 KB of your response body is kept in the delivery log.


3. Verifying signatures

Always verify before trusting a payload:

  1. Read the raw request body exactly as received (bytes → UTF-8 string). Do not parse and re-serialize JSON before verifying: key order or whitespace changes break the signature.
  2. Reject the request if VMeet-Webhook-Timestamp is more than 5 minutes away from your clock (replay protection).
  3. Compute HMAC-SHA256(secret, "<timestamp>.<raw body>") as lowercase hex. Use the whole secret string, including the whsec_ prefix, as the key.
  4. Accept if it equals any v1= value in VMeet-Signature (comma-separated), using a constant-time comparison.

Node.js (Express)

js
const crypto = require('crypto');
const express = require('express');

const SECRET = process.env.VMEET_WEBHOOK_SECRET; // whsec_…
const TOLERANCE_SECONDS = 300;

function verifyVMeetSignature(rawBody, timestampHeader, signatureHeader, secret = SECRET) {
  const timestamp = Number(timestampHeader);
  if (!Number.isInteger(timestamp)) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`, 'utf8')
    .digest();
  return String(signatureHeader || '')
    .split(/[\s,]+/)
    .filter((part) => part.startsWith('v1='))
    .some((part) => {
      const candidate = Buffer.from(part.slice(3), 'hex');
      return candidate.length === expected.length && crypto.timingSafeEqual(candidate, expected);
    });
}

const app = express();
// Keep the raw body for this route — do NOT use express.json() before verifying
app.post('/vmeet/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
  const rawBody = req.body.toString('utf8');
  if (
    !verifyVMeetSignature(rawBody, req.get('VMeet-Webhook-Timestamp'), req.get('VMeet-Signature'))
  ) {
    return res.status(400).send('invalid signature');
  }
  const event = JSON.parse(rawBody);
  if (await alreadyProcessed(event.id)) return res.sendStatus(200); // idempotency, see §4
  await enqueue(event); // process asynchronously
  res.sendStatus(200);
});

(Next.js route handler: const rawBody = await request.text(); then the same check.)

PHP (8.0+)

php
<?php
const VMEET_TOLERANCE_SECONDS = 300;

function vmeet_verify_signature(string $rawBody, ?string $timestamp, ?string $signatureHeader, string $secret): bool {
    if ($timestamp === null || !ctype_digit($timestamp)) return false;
    if (abs(time() - (int) $timestamp) > VMEET_TOLERANCE_SECONDS) return false;

    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    foreach (preg_split('/[\s,]+/', (string) $signatureHeader, -1, PREG_SPLIT_NO_EMPTY) as $part) {
        if (str_starts_with($part, 'v1=') && hash_equals($expected, substr($part, 3))) {
            return true;
        }
    }
    return false;
}

$rawBody   = file_get_contents('php://input');                 // raw bytes, not $_POST
$timestamp = $_SERVER['HTTP_VMEET_WEBHOOK_TIMESTAMP'] ?? null;
$signature = $_SERVER['HTTP_VMEET_SIGNATURE'] ?? null;

if (!vmeet_verify_signature($rawBody, $timestamp, $signature, getenv('VMEET_WEBHOOK_SECRET'))) {
    http_response_code(400);
    exit('invalid signature');
}

$event = json_decode($rawBody, true);
// … dedupe on $event['id'], queue the work …
http_response_code(200);

(Laravel: $request->getContent() gives the raw body; $request->header('VMeet-Signature').)

Python 3.10+ (Flask)

python
import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

SECRET = os.environ["VMEET_WEBHOOK_SECRET"]  # whsec_…
TOLERANCE_SECONDS = 300


def verify_vmeet_signature(raw_body: bytes, timestamp: str | None, signature_header: str | None, secret: str = SECRET) -> bool:
    if not timestamp or not timestamp.isdigit():
        return False
    if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
        return False
    signed = timestamp.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    parts = (signature_header or "").replace(",", " ").split()
    return any(p.startswith("v1=") and hmac.compare_digest(p[3:], expected) for p in parts)


app = Flask(__name__)


@app.post("/vmeet/webhooks")
def vmeet_webhook():
    raw = request.get_data()  # raw bytes — call before request.json
    if not verify_vmeet_signature(raw, request.headers.get("VMeet-Webhook-Timestamp"), request.headers.get("VMeet-Signature")):
        abort(400)
    event = request.get_json()
    # … dedupe on event["id"], queue the work …
    return "", 200

(Django: request.body is the raw body; headers are request.headers["VMeet-Signature"].)

Rotating your secret

POST …/rotate-secret returns a new secret. For the next 24 hours every delivery carries two signatures (new and old), so you can deploy the new secret at your own pace: verification with either secret succeeds. After 24 hours only the new secret signs.


4. Idempotency, ordering and retries

  • Deliveries are at-least-once. The same event can arrive more than once (a retry after your server timed out but had processed it, a manual redelivery, a GurukulLive worker restart mid-request). Store processed ids (for example a unique index on the event id, kept for ≥ 7 days) and ignore repeats.
  • Order is not guaranteed. participant.left can arrive before participant.joined, a retry of an old event can arrive after a newer one. Payloads carry the full current state of the object plus timestamps (startedAt, endedAt, joinedAt, leftAt, …) — compare them instead of relying on arrival order.
  • Retries. A delivery fails on a non-2xx response, a timeout (10 s), a TLS/connection error or a redirect. It is retried after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h (±10% jitter) — 8 attempts over ~45 hours. Each attempt is re-signed with a fresh timestamp; the event id stays the same.
  • Auto-disable. If every delivery to an endpoint has failed for 3 days, GurukulLive disables the endpoint, stops its queued retries, and notifies the organization's owners and admins (in-app notification + email). Fix your receiver, re-enable the endpoint (PATCH … {"status": "ACTIVE"}), and redeliver what you missed from the delivery log.
  • Delivery log. Every attempt's status code, duration and first 1 KB of your response are kept for 30 days. redeliver re-sends the same event (same id) once.
  • Test events. POST …/test sends {"type": "webhook.test"} once (no retries). It also works on a disabled endpoint, and never affects the endpoint's failure count.

5. Security checklist for receivers

  • Verify the signature and the timestamp on every request; reject otherwise with 400.
  • Use HTTPS with a valid certificate (required in production).
  • Don't put secrets in the URL path or query string — the signing secret is the authentication.
  • Treat payload content as data: names, titles and descriptions come from end users.
  • Respond fast; do heavy work in a background job.

6. Event catalog (v1)

TypeWhendata type (@vmeet/types)
meeting.createdA meeting or live class is scheduled/createdWebhookMeetingData
meeting.updatedTitle, schedule or settings changedWebhookMeetingData
meeting.startedThe first participant/host started the roomWebhookMeetingData
meeting.endedThe room ended (host ended it or everyone left)WebhookMeetingData
meeting.cancelledA scheduled meeting/class was cancelledWebhookMeetingData
participant.joinedSomeone joined the roomWebhookParticipantData
participant.leftSomeone left (includes durationSeconds)WebhookParticipantData
participant.waitingSomeone is waiting in the waiting roomWebhookWaitingRoomData
participant.admittedA host admitted themWebhookWaitingRoomData
recording.startedCloud recording startedWebhookRecordingData
recording.readyThe recording file is processed and playableWebhookRecordingData
recording.failedRecording/processing failed (failureReason)WebhookRecordingData
recording.deletedA recording was deleted (deletedAt)WebhookRecordingData
transcript.readyThe AI transcript of a recording is ready (sent together with insights.ready)WebhookTranscriptData
insights.readyAI lecture notes of a recording are ready (summary, chapters, quiz draft)WebhookInsightsData
attendance.finalizedAttendance for a class session is finalWebhookAttendanceData
user.created / user.updated / user.deactivatedOrg member provisioned / changed / deactivated (also when a member deletes their account)WebhookUserData
enrollment.created / enrollment.removedA student was enrolled in / removed from a class or batchWebhookEnrollmentData
class.created / class.updatedAn academic class was created / changedWebhookClassData
poll.closedA host closed an in-meeting poll or quiz (poll + results, quiz score summary)WebhookPollData
question.answeredA host answered a Q&A question (askedBy is null for anonymous questions)WebhookQuestionData

kind is MEETING for regular meeting rooms and CLASS for academic live classes. Fields ending in Id are GurukulLive ids; external…Id fields echo your own ids when you provisioned the object with one (otherwise null). All timestamps are ISO-8601 UTC. New optional fields may be added at any time — ignore fields you don't know.

meeting.started

json
{
  "id": "evt_01JA7W5Q3Y8V6M2K4N0R9T1XZC",
  "type": "meeting.started",
  "createdAt": "2026-10-14T10:00:00.000Z",
  "organizationId": "org_123",
  "data": {
    "meetingId": "cm2lm1x0p0001",
    "externalMeetingId": "lms-lecture-8812",
    "kind": "CLASS",
    "title": "Physics — Grade 10 A",
    "type": "CLASSROOM",
    "status": "LIVE",
    "slug": "phy-10a-x7k2",
    "hostUserId": "usr_teacher_1",
    "classId": "cls_10a",
    "externalClassId": "grade-10-a",
    "courseId": "crs_phy",
    "batchId": null,
    "scheduledStart": "2026-10-14T10:00:00.000Z",
    "scheduledEnd": "2026-10-14T11:00:00.000Z",
    "startedAt": "2026-10-14T09:59:41.000Z",
    "endedAt": null,
    "durationSeconds": null
  }
}

meeting.ended has the same shape with "status": "ENDED", endedAt, durationSeconds and endedReason: HOST_ENDED (a host ended it, also when ended through the API), ROOM_FINISHED (everyone left) or PLAN_LIMIT (the plan's meeting-duration cap ended it). API and TIMEOUT are declared in the type but not sent today; treat unknown values as "ended". meeting.created/updated/cancelled use the same shape.

participant.joined / participant.left

json
{
  "id": "evt_01JA7W6B2C9D8E7F6G5H4J3K2M",
  "type": "participant.left",
  "createdAt": "2026-10-14T10:47:12.000Z",
  "organizationId": "org_123",
  "data": {
    "meetingId": "cm2lm1x0p0001",
    "externalMeetingId": "lms-lecture-8812",
    "kind": "CLASS",
    "participantId": "cm2lm2a9z0007",
    "user": {
      "id": "usr_student_42",
      "externalId": "stu-42",
      "name": "Asha Patel",
      "email": null,
      "role": "STUDENT"
    },
    "device": "android",
    "joinedAt": "2026-10-14T10:01:05.000Z",
    "leftAt": "2026-10-14T10:47:10.000Z",
    "durationSeconds": 2765
  }
}

device is web, android, ios or unknown. On participant.joined, leftAt and durationSeconds are null. Guests without a GurukulLive account have user.id: null.

participant.waiting / participant.admitted

json
{
  "id": "evt_01JA7W5Z…",
  "type": "participant.admitted",
  "createdAt": "2026-10-14T10:02:30.000Z",
  "organizationId": "org_123",
  "data": {
    "meetingId": "cm2lm1x0p0001",
    "externalMeetingId": null,
    "kind": "MEETING",
    "user": {
      "id": null,
      "externalId": null,
      "name": "Guest — Rahul",
      "email": null,
      "role": "PARTICIPANT"
    },
    "requestedAt": "2026-10-14T10:01:58.000Z",
    "admittedAt": "2026-10-14T10:02:30.000Z",
    "admittedBy": "usr_teacher_1"
  }
}

recording.ready

json
{
  "id": "evt_01JA7XA1…",
  "type": "recording.ready",
  "createdAt": "2026-10-14T11:03:20.000Z",
  "organizationId": "org_123",
  "data": {
    "recordingId": "rec_1760436000000_a1b2c3d4e5f6",
    "meetingId": "cm2lm1x0p0001",
    "externalMeetingId": "lms-lecture-8812",
    "kind": "MEETING",
    "status": "READY",
    "startedAt": "2026-10-14T10:00:12.000Z",
    "endedAt": "2026-10-14T10:58:40.000Z",
    "durationSeconds": 3508,
    "fileSizeBytes": 412334080,
    "mimeType": "video/mp4",
    "failureReason": null,
    "deletedAt": null
  }
}

The payload never contains media URLs. Request a short-lived playback URL / embeddable player with POST /api/v1/public/recordings/:id/playback. recording.started (status RECORDING), recording.failed (status FAILED, failureReason) and recording.deleted (status DELETED, deletedAt) use the same shape.

transcript.ready / insights.ready

Sent by the background worker when AI lecture notes for a recording finish successfully: after the automatic run that follows recording.ready, and again after every manual regenerate of the notes (new event ids each time). The automatic run only happens when AI notes are configured on the server, enabled for the organization and included in its plan. Both events are queued one after the other (transcript.ready first) — but delivery order is not guaranteed (§4). Nothing is sent when processing fails. recordingId, meetingId and kind match the recording.ready event of the same recording.

json
{
  "id": "evt_01JA7XB2…",
  "type": "transcript.ready",
  "createdAt": "2026-10-14T11:09:45.000Z",
  "organizationId": "org_123",
  "data": {
    "recordingId": "rec_1760436000000_a1b2c3d4e5f6",
    "meetingId": "cm2lm1x0p0001",
    "kind": "MEETING",
    "language": "gu",
    "wordCount": 6421
  }
}
json
{
  "id": "evt_01JA7XB3…",
  "type": "insights.ready",
  "createdAt": "2026-10-14T11:09:45.000Z",
  "organizationId": "org_123",
  "data": {
    "recordingId": "rec_1760436000000_a1b2c3d4e5f6",
    "meetingId": "cm2lm1x0p0001",
    "kind": "MEETING",
    "language": "gu",
    "summary": {
      "overview": "Newton's laws of motion with worked examples.",
      "bullets": ["First law: inertia", "F = ma"]
    },
    "chapters": [
      { "title": "Introduction", "startSeconds": 0 },
      { "title": "Second law", "startSeconds": 742 }
    ],
    "quizQuestionCount": 5,
    "durationSeconds": 3504
  }
}
FieldTypeNotes
recordingId, meetingIdstringBoth events
kind"MEETING" \"CLASS"
languagestring \| null, optionalBoth events. ISO 639-1 code of the dominant spoken language (gu, hi, en, …)
wordCountnumber \| null, optionaltranscript.ready only. Words in the transcript
summary{ overview: string; bullets: string[] } \| null, optionalinsights.ready only
chaptersArray<{ title: string; startSeconds: number }>, optionalinsights.ready only. startSeconds from the start of the recording; may be empty
quizQuestionCountnumber \| null, optionalinsights.ready only. 0 when no quiz draft was generated
durationSecondsnumber \| null, optionalinsights.ready only. Length of the transcribed audio — shorter than the recording when it exceeded the maximum AI audio length (server or plan limit)

There is no externalMeetingId in these two events. The payloads never contain the transcript text or the notes themselves; they are shown on the recording page in GurukulLive, and there is no public API endpoint for them yet.

attendance.finalized

json
{
  "id": "evt_01JA7XC4…",
  "type": "attendance.finalized",
  "createdAt": "2026-10-14T11:05:00.000Z",
  "organizationId": "org_123",
  "data": {
    "meetingId": "cm2lm1x0p0001",
    "externalMeetingId": "lms-lecture-8812",
    "kind": "CLASS",
    "classId": "cls_10a",
    "externalClassId": "grade-10-a",
    "attendanceSessionId": "att_77",
    "finalizedAt": "2026-10-14T11:05:00.000Z",
    "summary": { "total": 32, "present": 28, "late": 2, "absent": 2 },
    "records": [
      {
        "user": {
          "id": "usr_student_42",
          "externalId": "stu-42",
          "name": "Asha Patel",
          "role": "STUDENT"
        },
        "status": "PRESENT",
        "durationSeconds": 2765,
        "joinedAt": "2026-10-14T10:01:05.000Z",
        "leftAt": "2026-10-14T10:47:10.000Z"
      }
    ]
  }
}

user.created / user.updated / user.deactivated

json
{
  "id": "evt_01JA7Y00…",
  "type": "user.created",
  "createdAt": "2026-10-14T08:00:00.000Z",
  "organizationId": "org_123",
  "data": {
    "userId": "usr_student_42",
    "externalId": "stu-42",
    "email": null,
    "name": "Asha Patel",
    "role": "STUDENT",
    "status": "ACTIVE",
    "avatarUrl": null,
    "createdAt": "2026-10-14T08:00:00.000Z",
    "updatedAt": "2026-10-14T08:00:00.000Z",
    "deactivatedAt": null
  }
}

user.deactivated is also sent to every organization in which a member was active when that member deletes their own account (DELETE /users/me, IDENTITY_TENANCY.md §7). The account is already anonymised by then, so the payload carries "name": "Deleted user", "email": null, "avatarUrl": null and "status": "DEACTIVATED"; userId, externalId and role are the member's last values. Treat it like a removal and keep your own records keyed by userId / externalId.

enrollment.created / enrollment.removed

json
{
  "id": "evt_01JA7Y1…",
  "type": "enrollment.created",
  "createdAt": "2026-10-14T08:01:00.000Z",
  "organizationId": "org_123",
  "data": {
    "enrollmentId": "enr_901",
    "user": {
      "id": "usr_student_42",
      "externalId": "stu-42",
      "name": "Asha Patel",
      "role": "STUDENT"
    },
    "classId": "cls_10a",
    "externalClassId": "grade-10-a",
    "batchId": "bat_2026",
    "courseId": "crs_phy",
    "status": "ACTIVE",
    "enrolledAt": "2026-10-14T08:01:00.000Z",
    "removedAt": null
  }
}

class.created / class.updated

json
{
  "id": "evt_01JA7Y2…",
  "type": "class.created",
  "createdAt": "2026-10-01T06:00:00.000Z",
  "organizationId": "org_123",
  "data": {
    "classId": "cls_10a",
    "externalClassId": "grade-10-a",
    "name": "Grade 10",
    "section": "A",
    "academicYear": "2026-2027",
    "description": null,
    "createdAt": "2026-10-01T06:00:00.000Z",
    "updatedAt": "2026-10-01T06:00:00.000Z"
  }
}

webhook.test

json
{
  "id": "evt_01JA7Y3…",
  "type": "webhook.test",
  "createdAt": "2026-10-14T09:00:00.000Z",
  "organizationId": "org_123",
  "data": { "message": "This is a test event sent from GurukulLive.", "endpointId": "cm2…" }
}

7. Local development

Point an endpoint at a tunnel (ngrok, Cloudflare Tunnel) that exposes your local receiver over HTTPS. On a self-hosted, non-production GurukulLive you can instead set WEBHOOKS_ALLOW_PRIVATE_TARGETS=true to allow http://localhost receivers; this switch is ignored in production.