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 for data frequently in order to be 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)

Curriculum:

  • Curriculum created (curriculum.created)
  • Curriculum updated (curriculum.updated)
  • Curriculum visibility updated (curriculum.updated.visibility)
  • Curriculum status updated (curriculum.updated.status)
  • Curriculum deleted (curriculum.deleted)
  • Curriculum assigned to a user (curriculum.user.assigned)
  • A user made progress on a curriculum (curriculum.user.progress)
  • A user completed a curriculum (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)

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 has been enabled in your academy, select Admin in the top navigation bar. Under Webhooks on the left, select Security Policies.

Webhooks-03.png

Then Add New.

Webhooks-04.png

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

Webhooks-04b.png

Save when finished.

Creating an Endpoint

Select Admin in the top navigation bar. Under Webhooks on the left, select Endpoints.

Webhooks-05.png

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 your are ready to use them.

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, 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

Curriculum

id: int

title: string

description: string

externalId: string

image: string

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

Curriculum 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

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 signing secret. The signature can be used to verify that the message is 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 webhook service. Therefore customers should consider implementing verification.

Verifying

Each webhook call includes three headers:

wh-id: the unique identifier of the webhook to be sent

wh-timestamp: timestamp of request creation (number of seconds since the Unix Epoch January 1 1970 00:00:00 GMT)

wh-signature: base64 encoded signature

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

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

  1. Check timestamp has an acceptable tolerance.
  2. Create the signable content. The signable content is wh-id, wh-timestamp, request
    payload concatenated by 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.

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?
0 out of 0 found this helpful