Webhooks in Schoox

Introduction

Webhooks service is a way for Schoox to provide third-party applications with real-time information. A webhook delivers data to third-party applications as it happens, whereas in typical APIs, the customer must poll frequently to stay close to real-time. This makes webhooks more efficient both for the producer (Schoox) and consumers (customer third-party applications).

Webhooks-01.png

Webhook Events

Webhook service relies on events. Customer third-party applications can subscribe to any of these events.


User:

  • User created (user.created)
  • User deactivated (user.deactivated)

Course:

  • Course created (course.created)
  • Course updated (course.updated)
  • Course visibility updated (course.updated.visibility)
  • Course status updated (course.updated.status)
  • Course assigned to a user (course.user.assigned)
  • A user made progress on a course (course.user.progress)
  • A user completed a course (course.user.completed)

Learning path:

  • Learning path created (curriculum.created)
  • Learning path updated (curriculum.updated)
  • Learning path visibility updated (curriculum.updated.visibility)
  • Learning path status updated (curriculum.updated.status)
  • Learning path deleted (curriculum.deleted)
  • Learning path assigned to a user (curriculum.user.assigned)
  • A user made progress on a Learning path (curriculum.user.progress)
  • A user completed a Learning path (curriculum.user.completed)

Event:

  • ILT Event created (ilt.event.created)
  • ILT Event updated (ilt.event.updated)
  • ILT Event updated status (ilt.event.updated.status)

On the Job Training:

  • On the Job training signed by the trainee (ojt.trainee.signed)

Skill:

  • Skill created (skill.created)
  • Skill updated (skill.updated)
  • Skill deleted (skill.deleted)
  • Skill assigned to a user (skill.user.assigned)
  • Skill unassigned from a user (skill.user.unassigned)

Skill Association:

  • Skill course association created (skill.course.association.created)
  • Skill course association deleted (skill.course.association.deleted)
  • Skill job association created (skill.job.association.created)
  • Skill job association updated (skill.job.association.updated)
  • Skill job association deleted (skill.job.association.deleted)
  • Skill exam question association created (skill.exam.question.association.created)
  • Skill exam question association deleted (skill.exam.question.association.deleted)
  • Skill micro step association created (skill.micro.step.association.created)
  • Skill micro step association updated (skill.micro.step.association.updated)
  • Skill micro step association deleted (skill.micro.step.association.deleted)
  • Skill micro course association created (skill.micro.course.association.created)
  • Skill micro course association updated (skill.micro.course.association.updated)
  • Skill micro course association deleted (skill.micro.course.association.deleted)
  • Skill micro assessment association created (skill.micro.assessment.association.created)
  • Skill micro assessment association updated (skill.micro.assessment.association.updated)
  • Skill micro assessment association deleted (skill.micro.assessment.association.deleted)
  • Skill micro assessment step association created (skill.micro.assessment.step. association.created)
  • Skill micro assessment step association updated (skill.micro.assessment.step. association.updated)
  • Skill micro assessment step association deleted (skill.micro.assessment.step. association.deleted)

Skill Group:

  • Skill group created (skill.group.created)
  • Skill group updated (skill.group.updated)
  • Skill group deleted (skill.group.deleted)
  • Skill added to group (skill.group.skill.added)
  • Skill removed from group (skill.group.skill.removed)

Skill Category:

  • Skill category created (skill.category.created)
  • Skill category updated (skill.category.updated)
  • Skill category deleted (skill.category.deleted)

Webhook Endpoints

Endpoints are the URLs that messages will be sent to. When you configure a Webhook Endpoint, you must provide the URL, name, associated security policy, and the subscribed events.

Webhooks-07.png

Every Webhook Endpoint is bound to one or many events. When an event is triggered, if the customer has configured an endpoint bonded with the triggered event, an external POST HTTP request will be made by the webhook service. 

To enable Webhooks in your academy, please ask your Schoox representative to activate it. 

Creating a Security Policy

Security Policies are internal configurations for endpoints in order to authenticate to an endpoint. One academy could have multiple security policies. An endpoint could be configured with one security policy. The configuration is available only for super admins.

After Webhooks have been enabled in your academy, select Security Policies. 

From the Academy Workspace, select Academy Admin -> Configuration -> Webhooks

Webhooks.jpg

Go to the Security Policies tab and select Add New.

Fill in the fields provided, and select the desired authentication type from the drop-down menu.

Webhooks-04b.png

Save when finished.

Authentication Types

The following authentication types are supported:

TypeDescription
BASICHTTP Basic Authentication using username and password
DIGESTHTTP Digest Authentication using username and password
NTLMNTLM Authentication using username and password
TOKENToken-based authentication with optional prefix (e.g., Bearer)
OAUTHOAuth 2.0 authentication using client credentials grant


OAuth Configuration Options

When configuring OAuth authentication, the following options are available:

FieldRequiredDescription
OAuth URLYesThe token endpoint URL
Client IDYesOAuth client identifier
Client SecretYesOAuth client secret
Grant TypeYesOAuth grant type (supports client_credentials)
AudienceNoOAuth audience parameter
ScopeNoOAuth scope parameter
ResourceNoOAuth resource parameter
Extra HeadersNoAdditional headers for the OAuth request as JSON

Creating an Endpoint

From the Webhooks setting, go to the Endpoints tab.

Then Add New.

Webhooks-06.png

Complete the fields provided, and check the box next to each event that pertains.

Webhooks-07.png

Save when finished.

  • Endpoint names are visual identifiers for easy separation of endpoints. The Endpoint Name field is mandatory.
  • Endpoint URL is the URL of the third-party application that will receive webhook events. The Endpoint URL field is mandatory.
  • The Webhook Endpoint will be triggered only for the selected endpoint events. Endpoint Events are mandatory.
  • Endpoints are inactive by default. Activate each when you're ready to use it.

Webhooks-08.png

Request Failure Handling

The Webhook Service expects a response status code in the range 2xx.  If the response is not in the accepted range or a timeout occurs (more than 60 seconds to respond) it is marked as failed.

The system will retry sending these events 5 times, with a retry interval of 5 minutes.

Consuming Webhooks

Base Schema

Every triggered webhook have a base JSON body:

{
     "event": "course.updated.visibility", "payload": { 

           ...

     } 

} 
  • “event” value is the triggered event.
  • “payload” value containing entity data based on the triggered event.

Webhooks-09.png

Payload

For every webhook trigger, the webhook service builds the corresponding "payload" based on the triggered event. The "payload" may contain multiple entities.  For example, if the event is the course.user.progress, the system will fill the payload with three entities:  course, user, and course progress.

Entity Payload Schema

Course

id: int

title: string

description: string

externalId: string

image: string

status: {'ACTIVE', 'ARCHIVED', 'RETIRED', 'DELETED'} 

visibilityStatus: {'PUBLIC', 'PRIVATE'} 


Course Progress

progress: int

completionDate: "DateTime as string at ISO 8601 format" 

certificationVerificationCode: string 


Learning path

id: int

title: string

description: string

externalId: string

image: string

status: {'ACTIVE', 'ARCHIVED'} visibilityStatus: {'PUBLIC', 'PRIVATE'} 


Learning Path Progress

progress: int

completionDate: "DateTime as string at ISO 8601 format"

certificationVerificationCode: string 


ILT Event

id: int

title: string

description: string

image: string

status: {'ACTIVE', 'ARCHIVED', 'CANCELED', 'DELETED'} 


On-the-Job Training

id: int

title: string


User

id: int

firstName: string

lastName: string

email: string

externalIds: string array


Skill

id: int

status: {'ACTIVE', 'ARCHIVED', 'DELETED'}

externalId: string

academyId: int

externalIds: string array

createdAt: "DateTime as string in ISO 8601 format"

updatedAt: "DateTime as string in ISO 8601 format"

translations: array of Skill Translation objects


Skill Translation

languageId: int

languageName: string

skillId: int

name: string

description: string


Skill Group

id: int

name: string

externalId: string

academyId: int


Skill Category

id: int

name: string

externalId: string

academyId: int


Skill Course Association

sourceType: string (value: 'COURSE')

sourceId: int

academyId: int

skillId: int

createdAt: "DateTime as string in ISO 8601 format"


Skill Job Association

id: int

sourceId: int

skillId: int

weight: float

createdAt: "DateTime as string in ISO 8601 format"


Skill Exam Question Association

sourceType: string (value: 'EXAM_QUESTION')

sourceId: int

examQuestionId: int

metricId: int

academyId: int


Skill Entity Association

Used for micro step, micro course, micro assessment, and micro assessment step associations.

id: int

sourceType: {'MICRO_COURSE', 'COURSE', 'EXAM', 'MICRO_STEP', 'OJT', 'JOB', 
'PERFORMANCE_REVIEW', 'MICRO_ASSESSMENT', 'MICRO_ASSESSMENT_STEP'}

sourceId: int

academyId: int

skillId: int

createdAt: "DateTime as string in ISO 8601 format"

creatorId: int


Job

id: int

title: string

externalId: string

deletable: boolean

createdAt: string

updatedAt: string


Exam

id: int

name: string

description: string

externalId: string

type: string

publish: string

createdAt: string

updatedAt: string


Exam Question

id: int

type: string

question: string

details: string

points: int

creatorId: int

academyId: int

modifiedAt: string


Micro Course

id: int

title: string

description: string

externalId: string

visibility: string

status: string

image: string

createdAt: string

updatedAt: string


Micro Step

id: int

microCourseId: int

stepType: string

stepOrder: int

visibility: string

createdAt: string

updatedAt: string


Micro Assessment

id: int

title: string

description: string

externalId: string

status: string

image: string

createdAt: string

updatedAt: string


Micro Assessment Step

id: int

microAssessmentId: int

stepType: string

stepOrder: int

visibility: string

createdAt: string

updatedAt: string


Verifying Webhook Messages

Why

Attackers can impersonate webhook services by simply sending fake data to an endpoint. In order to prevent these attacks, the webhook service signs every webhook with a unique 256-bit signing secret. The signature can be used to verify that the message was produced by Schoox. 

Attackers intercept a valid payload - signature and create a similar HTTP request. The payload will pass the verification process. In order to prevent these attacks, the webhook service includes a timestamp of the webhook attempt. The customer can specify a delay tolerance when verifying the webhook. 

Those are potential security holes for third-party applications integrating with the webhook service. Therefore, customers should consider implementing verification. 

Verifying

Each webhook call includes three headers:

HeaderDescription
wh-idThe unique identifier of the webhook to be sent
wh-timestampTimestamp of request creation (number of seconds since the Unix Epoch January 1, 1970 00:00:00 GMT)
wh-signatureBase64 encoded signature with version prefix (e.g., v1,base64signature)


Webhook service generates a signature using a hash-based authentication code (HMAC) with SHA-256.

To verify that a message has been transmitted by Schoox, you should also sign the data and compare your signature with the signature in the wh-signature header.

Steps:

  1. Check that the timestamp has an acceptable tolerance.
  2. Create the signable content. The signable content is wh-id, wh-timestamp, request payload concatenated by a dot ( . ).

    e.g. ‘61d39.1639960072.{"event": "course.created", "payload": {}}‘
  3. Sign content using your endpoint secret, the secret has the structure whsec_xxxxxx you
    should sign content with the key after whsec_

    e.g for whsec_QEMBXPKpqJdcCNHgFqiFdz7G0apKrSNP you should sign with QEMBXPKpqJdcCNHgFqiFdz7G0apKrSNP
  4. Compare signatures.

Signature Format

The signature in the wh-signature header follows this format:

v1,<base64_encoded_signature>

Where v1 is the signature version.

Code Samples

PHP

// Verifying timestamp
$tolerance = 5*60; // 5 minutes
$timestamp = (int)$_SERVER['HTTP_WH-TIMESTAMP']; 

$now = time();
if ($timestamp < ($now - $tolerance) {
     throw new RuntimeException("Message timestamp too old"); } 

if ($timestamp > ($now + $tolerance) {
     throw new RuntimeException("Message timestamp too new"); 

} 

// Verifying signature


$id = $_SERVER['HTTP_WH-ID'];
$incommingSignature = $_SERVER['HTTP_WH-SIGNATURE']; 
$parsedSignature = explode(',', $incommingSignature, 2)[1] 

$secret = 'QEMBXPKpqJdcCNHgFqiFdz7G0apKrSNP'; 

$signable = "{$id}.{$timestamp}.{$payload}"; 
$hexHash = hash_hmac('sha256', $signable, $secret); 
$signature = base64_encode(pack('H*', $hexHash)); 

if (hash_equals($signature, $parsedSignature)) {
     echo "Webhook message is verified and safe to use!" 

} 

 

NodeJS / Javascript

import * as utf8 from "@stablelib/utf8"; 
import * as base64 from "@stablelib/base64"; 
import * as sha256 from "fast-sha256"; 

// Verifying timestamp
const tolerance = 5 * 60; // 5 minutes
const timestamp = parseInt(req.headers['wh-timestamp'], 10); 

const now = Math.floor(Date.now() / 1000);

if (timestamp < (now - tolerance) {
throw new Error("Message timestamp too old"); 
} 

if (timestamp > (now + tolerance) {
throw new Error("Message timestamp too new"); 
} 

// Verifying signature
const id = req.headers['wh-id'];
const incommingSignature = req.headers['wh-signature']; 
const parsedSignature = incommingSignature.split(",")[1]; 

const secret = 'QEMBXPKpqJdcCNHgFqiFdz7G0apKrSNP';
const signable = utf8.encode(`${id}.${timestamp}.${payload}`); 

const signature = base64.encode(sha256.hmac(secret, signable)); 

if (signature === parsedSignature)) {
      console.log("Webhook message is verified and safe to use!"); 
} 

 

C# Web Server

using System.Net;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;


public class Startup

 

{

 

public void ConfigureServices(IServiceCollection services)

 

{

 

services.AddControllers();

 

}


public void Configure(IApplicationBuilder app, IWebHostEnvironment env)

 

{

 

if (env.IsDevelopment())

 

{

 

app.UseDeveloperExceptionPage();

 

}


app.UseRouting();


app.UseEndpoints(endpoints =>

 

{

 

endpoints.MapControllers();

 

});

 

}

 

}


public class Program

 

{

 

public static void Main(string[] args)

 

{

 

CreateHostBuilder(args).Build().Run();

 

}


public static IHostBuilder CreateHostBuilder(string[] args) =>

 

Host.CreateDefaultBuilder(args)

 

.ConfigureWebHostDefaults(webBuilder =>

 

{

 

webBuilder.UseStartup<Startup>();

 

});

 

 

 

}

 

 

C# Webhook Controller

using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Primitives;


[ApiController]

 

[Route("[controller]")]

 

public class WebhookController : ControllerBase

 

{

 

private const string Secret = "5wHHhKhZz5FNOj59nTCdE6RZpocYKkc7";

 

private const int Tolerance = 5 * 60; // 5 minutes


[HttpPost]

 

public async Task<IActionResult> Post()

 

{

 

// Verifying timestamp

 

if (!Request.Headers.TryGetValue("wh-timestamp", out StringValues timestampValues) ||

 

!int.TryParse(timestampValues, out int timestamp))

 

{

 

return BadRequest("Invalid or missing wh-timestamp header");

 

}


var now = (int)DateTimeOffset.UtcNow.ToUnixTimeSeconds();


if (timestamp < now - Tolerance)

 

{

 

return BadRequest("Timestamp expired");

 

}


if (timestamp > now + Tolerance)

 

{

 

return BadRequest("Timestamp is from the future");

 

}


// Verifying signature

 

if (!Request.Headers.TryGetValue("wh-id", out StringValues idValues) ||

 

!Request.Headers.TryGetValue("wh-signature", out StringValues signatureValues))

 

{

 

return BadRequest("Invalid headers");

 

}


var id = idValues.ToString();

 

var incomingSignature = signatureValues.ToString().Split(",")[1];


// Read the request body

 

string requestBody;

 

using (var reader = new StreamReader(Request.Body))

 

{

 

requestBody = await reader.ReadToEndAsync();

 

}


string signable = id + "." + timestamp + "." + requestBody;

 

byte[] secretBytes = Encoding.UTF8.GetBytes(Secret);

 

byte[] payloadBytes = Encoding.UTF8.GetBytes(signable);


HMACSHA256 hmac = new HMACSHA256(secretBytes);

 

byte[] hash = hmac.ComputeHash(payloadBytes);

 

string signature = Convert.ToBase64String(hash);


if (signature == incomingSignature)

 

{

 

Console.WriteLine("Webhook message is verified and safe to use!");

 

}

 

else {

 

return BadRequest("Invalid signature");

 

}

 

return Ok();

 

}

 

}

 

Was this article helpful?
3 out of 3 found this helpful