Webhooks

Webhooks let your systems react to changes in RTOPilot - a new student created, an enrolment cancelled, a status change - without polling. When an event happens, RTOPilot sends an HTTP POST to a URL you control with a JSON payload describing what happened.

Written By Manning Blackall

Last updated 28 days ago

Webhooks are configured per organisation under Developers β†’ Webhooks in the admin portal.

Configuring a webhook

Each webhook subscription consists of:

  • URL - the HTTPS endpoint RTOPilot will POST to.

  • Event - a single event type from the list below. To subscribe to multiple events, create one webhook per event.

  • Authentication - how RTOPilot should authenticate to your endpoint. See Authentication options below.

  • Active - toggle for pausing delivery without deleting the subscription.

A signing secret is generated automatically when the webhook is created and cannot be edited. Use it to verify that incoming requests really come from RTOPilot - see Verifying signatures below.

Testing a webhook

The Webhooks UI has a Test button that sends an example payload for the configured event to your URL. Test deliveries do not retry on failure and are rate-limited to one per webhook per 10 seconds.

You can review every delivery attempt - successful or failed, scheduled or delivered - from the webhook's detail page. Each attempt records the request payload, response status code, response body, and any error.

Events

RTOPilot currently fires six event types. All payloads share the same envelope (see Payload format); the data field varies by event.

  • student.created - a new student is added to the organisation.

  • student.merged - two student records are merged. The payload describes the surviving student and includes the ID of the record that was merged in.

  • enrolment.created - an enrolment is created (a student is enrolled into a class).

  • enrolment.cancelled - an existing enrolment is cancelled.

  • enrolment.status.updated - an enrolment's status changes (for example ACTIVE β†’ COMPLETED).

  • enrolment.application.created - a student submits an enrolment application for a course (before being placed into a class).

Payload format

The HTTP request body is a JSON object with this envelope:

{
  "id": "ckxyz...",
  "eventName": "enrolment.created",
  "created": 1714358400000,
  "attemptCount": 0,
  "data": { ... }
} 

Envelope fields:

  • id (string) - unique delivery ID. The same id is reused across retries of the same delivery. Use this for idempotency on your side.

  • eventName (string) - one of the event names listed above. Also sent as the X-Event header.

  • created (number) - Unix timestamp in milliseconds of when this delivery attempt was built.

  • attemptCount (number) - zero-based attempt number. 0 for the first attempt, 1 for the first retry, etc.

  • data (object) - the event-specific payload. See below.

data payloads by event

Every data object includes:

  • object - "student" or "enrolment"

  • trainingOrganisationId - the ID of the organisation the event belongs to

Plus event-specific fields.

student.created

{
  "object": "student",
  "trainingOrganisationId": "...",
  "id": "...",
  "email": "alex@example.com",
  "firstName": "Alex",
  "lastName": "Smith",
  "phoneNumber": "0400000000"
}

lastName and phoneNumber may be null.

student.merged

Same shape as student.created, plus:

{ "mergedFromStudentId": "..." } 

id is the surviving student; mergedFromStudentId is the record that was merged in and is no longer available.

enrolment.created

{
  "object": "enrolment",
  "trainingOrganisationId": "...",
  "id": "...",
  "status": "ACTIVE",
  "student": {
    "id": "...",
    "email": "...",
    "firstName": "...",
    "lastName": "...",
    "phoneNumber": "..."
  },
  "class": {
    "id": "...",
    "name": "...",
    "courseId": "...",
    "sessions": [
      {
        "name": "...",
        "startTime": "2026-05-01T09:00:00.000Z",
        "endTime": "2026-05-01T17:00:00.000Z",
        "address": "..."
      }
    ]
  }
}

enrolment.cancelled and enrolment.status.updated

{
  "object": "enrolment",
  "trainingOrganisationId": "...",
  "id": "...",
  "status": "CANCELLED",
  "studentId": "...",
  "classId": "..."
}

status reflects the new status of the enrolment. For enrolment.status.updated, this can be any value of the enrolment-status enum (for example ACTIVE, COMPLETED, CANCELLED).

enrolment.application.created

{
  "object": "enrolment",
  "trainingOrganisationId": "...",
  "id": "...",
  "courseId": "...",
  "student": {
    "id": "...",
    "email": "...",
    "firstName": "...",
    "lastName": "...",
    "phoneNumber": "..."
  }
}

The Webhooks UI exposes a View example payloads action that returns the live example payloads from the API, so what you see there is always current.

Headers

Every webhook request includes the following headers:

  • Content-Type: application/json

  • X-Signature - HMAC-SHA256 signature of the data payload (see below). Format: sha256=<hex>.

  • X-Event - the event name (e.g. enrolment.created). Convenient for routing without parsing the body.

  • X-Delivery-ID - the delivery id from the envelope. Use it for idempotent processing.

  • X-Timestamp - ISO-8601 timestamp of when this attempt was sent.

If you also configured request authentication (Basic, Bearer, or a custom header), those headers are added on top of the ones above.

Verifying signatures

Each webhook subscription has a unique signing secret, shown in the Webhooks UI when you open the webhook's detail page. RTOPilot signs every request with HMAC-SHA256 using that secret.

IMPORTANT - the signature is computed over JSON.stringify(payload.data), i.e. the inner data object only, not the full envelope. Verify accordingly.

The signature is sent as the X-Signature header in the format sha256=<hex>. To verify a request:

import crypto from 'crypto';

function isValidSignature(rawBody, headerValue, secret) {
  const payload = JSON.parse(rawBody);
  const expected =
    'sha256=' +
    crypto
      .createHmac('sha256', secret)
      .update(JSON.stringify(payload.data))
      .digest('hex');

  // Constant-time comparison to avoid timing attacks
  const a = Buffer.from(headerValue);
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Reject any request that fails the signature check.

Authentication options

Beyond the signature, you can require RTOPilot to authenticate to your endpoint. Pick one of:

  • NONE - no additional auth headers. Signature verification is your only check.

  • BASIC - Authorization: Basic <base64(username:password)> using the username and password you provide.

  • BEARER - Authorization: Bearer <token> using the token you provide.

  • CUSTOM_HEADER - a header with a name and value of your choice (e.g. X-My-Auth: ...).

For most receivers, NONE plus signature verification is sufficient. Use one of the others if you have an existing edge gateway that requires its own credentials.

Delivery and retries

  • Requests time out after 10 seconds. A timeout is treated as a failed delivery and retried.

  • Any non-2xx response is treated as a failed delivery and retried.

  • A delivery is attempted up to 4 times in total (the initial attempt plus 3 retries).

  • The retry schedule is exponential: roughly 5 minutes, then 1 hour, then 12 hours, then 24 hours after the previous attempt. After the 4th failed attempt, the delivery is given up on.

  • Test deliveries from the UI never retry.

  • Delivery is at-least-once, not exactly-once. Network glitches between your endpoint returning 2xx and RTOPilot recording the result can produce duplicate deliveries. Use the X-Delivery-ID (the envelope id) for idempotency.

Every attempt - pending, in-flight, delivered, or failed - is recorded in the database and visible in the Webhooks UI.

Best practices

  1. Verify the signature on every request. The signed payload is the data object - see the example above.

  2. Respond fast. Return 2xx as soon as you have durably accepted the event (e.g. enqueued it). Heavy work belongs in a background worker. The 10-second timeout is firm.

  3. Be idempotent. Use X-Delivery-ID to deduplicate retries. If your handler is naturally idempotent (e.g. an upsert keyed on a stable ID), even simpler.

  4. Don't trust the order. Deliveries can arrive out of order, especially after retries. If you depend on ordering, use the created timestamp on the envelope and the underlying entity IDs.

  5. Subscribe per event. One webhook subscribes to exactly one event. To handle multiple events at the same URL, create one subscription per event.