# Guide to Northpass Webhooks: Features and Best Practices

Northpass, now Gainsight Customer Education, is a learning platform for teaching customers and partners how to use a product. Companies run an "academy" of courses and learning paths, and the training data is most useful outside the LMS: a completed onboarding course can update a customer's health score in the CRM, and a new learner can be matched to an account. Northpass webhooks deliver those learner and course events as they happen.

This guide covers Northpass's 14 webhook subscriptions, the JSON:API-style payload, verifying the base64 `X-Northpass-Hmac-SHA256` header, and the retry rule that can switch off every webhook in your school.

## What are Northpass webhooks?

Webhooks come with Northpass's **Webhooks** app, available on the Premium plan. A school can have up to 10 subscriptions, created in the app or through the v2 API (`POST /v2/webhook_endpoints`, with a `url` and a list of `subscriptions`). Northpass only saves a subscription if the URL answers with a `2xx`, as a reachability check.

Deliveries are JSON `POST` requests shaped like JSON:API documents: a `data` object with the event's `id`, its `type` (the subscription name, ending in `_events`), `attributes`, `links` and `included` related records.

Every request is signed with the school's shared secret. `X-Northpass-Hmac-SHA256` holds a base64 HMAC-SHA256 of the raw body, the same scheme Shopify uses.

## Northpass webhook features

| Feature | Details |
| --- | --- |
| Availability | Webhooks app, Premium plan |
| Configuration | Webhooks app, or `POST /v2/webhook_endpoints` |
| Limit | 10 subscriptions per school |
| Signature | base64 HMAC-SHA256 |
| Signature header | `x-northpass-hmac-sha256` |
| Signed content | The raw request body |
| Key | The school's shared secret, shown in the Webhooks app |
| Body | JSON:API-style JSON |
| Event routing | Body field `data.type` |
| Retries | Hourly for 24 hours |
| After 24 hours of failure | All of the school's webhooks are disabled |

## Events and payloads

| `data.type` | Triggered When | Common Use Cases |
| --- | --- | --- |
| `learner_created_events` | A learner is created | Create the user in your CRM or app |
| `person_invited_events` | A person is invited | Track invitations |
| `person_activated_events` | A person activates their account | Mark the learner active |
| `learner_enrolled_to_course_events` | A learner is enrolled in a course | Record enrolments |
| `learner_started_course_events` | A learner starts a course | Progress tracking |
| `activity_completed_events` | A learner completes an activity | Fine-grained progress |
| `quiz_completed_events` | A learner completes a quiz | Store scores |
| `course_completed_events` | A learner completes a course | Issue credit, update the CRM |
| `course_published_events` | A course is published | Refresh your catalogue |
| `learner_registered_to_ilt_session_events` | A learner registers for an ILT session | Sync session rosters |
| `learning_path_learner_enrolled_events` | A learner is enrolled in a learning path | Record enrolments |
| `learning_path_started_events` | A learner starts a learning path | Progress tracking |
| `learning_path_item_completed_events` | A learner completes a learning path item | Progress tracking |
| `learning_path_completed_events` | A learner completes a learning path | Issue credit |

A few details from the docs: `learner_started_course_events` also fires when a learner restarts a course, `course_published_events` references the person who published, and `person_invited_events` applies to academies that use preregistration.

### Event Payload Structure

* `data.id` is the event ID, and `data.type` is the subscription name.
* `data.included` carries the related people, courses, activities or quizzes, though not on every event.
* `data.attributes` is specific to the event; a quiz completion, for example, includes the score `value` and the `minimum_passing_score`.

Two of Northpass's own examples disagree with the subscription list. The person-invited example has `data.type` set to `learner_created_events`, and the learning-path enrolment example uses `learner_enrolled_to_learning_path_events` rather than `learning_path_learner_enrolled_events`. Route on both spellings for learning-path enrolment, and log any unknown `data.type` instead of rejecting it.

## Setting up Northpass webhooks

### Prerequisites

* A Northpass school on the Premium plan, with the Webhooks app enabled
* Admin access to the school
* A publicly reachable HTTPS endpoint that already returns `2xx`

### Get Your Shared Secret

Open the **Webhooks** app; the school's shared secret is shown there. Store it as `WEBHOOK_SECRET` and use its UTF-8 bytes as the HMAC key, without decoding. One secret signs every webhook for the school.

### Register Your Endpoint

1. In the Webhooks app, add your endpoint URL (for example `https://example.com/webhooks/northpass`).
2. Choose the subscriptions to send there, such as `learner_created_events` and `course_completed_events`.
3. Save. Northpass checks that the URL responds with `2xx`.

Or call `POST /v2/webhook_endpoints` with a `url` and a list of `subscriptions`. Either way, keep to the 10-subscription limit.

### Test a delivery

Start a local receiver and forward requests with the [Hookdeck CLI](/docs/cli):

```bash
npx hookdeck-cli listen 3000 northpass --path /webhooks/northpass

```

Start the CLI before saving the subscription so the reachability check passes. Then create a test learner and enrol them in a course.

## Securing Northpass webhooks

Compute HMAC-SHA256 of the raw body with the shared secret, base64-encode the digest, and compare it with `X-Northpass-Hmac-SHA256` in constant time. Northpass's Ruby and Python samples use `Base64.encode64(...).strip` and `base64.b64encode(...)`. See [Webhook verification](https://developers.northpass.com/docs/webhook-verification).

```javascript
const crypto = require('node:crypto');

function decodeBase64(value) {
  if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
  const bytes = Buffer.from(value, 'base64');
  return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
    ? bytes : null;
}

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const signature = headers['x-northpass-hmac-sha256'];
  if (typeof signature !== 'string') return false;
  const key = Buffer.from(secret, 'utf8');
  const message = rawBody;
  const candidates = [signature];
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    const received = decodeBase64(candidate);
    if (!received) return false;
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

The handler deduplicates on `data.id` and accepts both spellings of the learning-path enrolment type.

```javascript
const express = require('express');

function createNorthpassApp({ seen, crm, progress }) {
  const app = express();
  app.post('/webhooks/northpass', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.WEBHOOK_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
    let parsed;
    try {
      parsed = JSON.parse(req.body.toString('utf8'));
    } catch {
      return res.sendStatus(400);
    }
    if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return res.sendStatus(400);
    const { data } = parsed;
    if (!data || typeof data !== 'object' || !data.id || typeof data.type !== 'string') {
      return res.sendStatus(400);
    }
    try {
      if (await seen(data.id)) return res.sendStatus(200);
      switch (data.type) {
        case 'learner_created_events':
        case 'person_activated_events':
          await crm.upsertLearner(data);
          break;
        case 'course_completed_events':
        case 'learning_path_completed_events':
          await crm.recordCompletion(data);
          break;
        case 'learning_path_learner_enrolled_events':
        case 'learner_enrolled_to_learning_path_events':
        case 'learner_enrolled_to_course_events':
          await progress.recordEnrolment(data);
          break;
        default:
          await progress.record(data.type, data);
      }
    } catch {
      return res.sendStatus(503);
    }
    return res.sendStatus(200);
  });
  return app;
}

```

No timestamp is signed, so a captured delivery verifies forever. `data.id` is the guard against processing it twice.

For a Northpass source, Hookdeck computes the base64 HMAC-SHA256 of the raw body with your shared secret and compares it with the header. Northpass always receives `200` from Hookdeck, so your school's webhooks aren't at risk of being disabled because of your outages; a mismatch is logged as failed verification and the request isn't forwarded. Hookdeck applies no timestamp tolerance.

> **Make Northpass webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) acknowledges every Northpass delivery, so an outage on your side can't trigger the 24-hour rule that disables all of your school's webhooks.

## Northpass webhook limitations and pain points

### One failing endpoint disables everything

**The Problem:** A single broken receiver stops every webhook in your school, including ones going to healthy systems.

**Why It Happens:** After a delivery has failed hourly for 24 hours, Northpass disables all of the school's webhooks and notifies the admin.

**Workarounds:**

* Alert on failures well before 24 hours pass, and fix or remove the failing subscription.
* After re-enabling, use the List all sent webhooks endpoint to find failed messages.

**How Hookdeck Can Help:** Hookdeck answers Northpass with `200` and holds the events, so a downstream outage never counts as a Northpass failure.

### Only 10 subscriptions

**The Problem:** You can't give every consumer its own set of subscriptions.

**Why It Happens:** Northpass caps a school at 10 subscriptions.

**Workarounds:**

* Send events to one receiver and fan out from there.

**How Hookdeck Can Help:** One Hookdeck source can receive all subscriptions and route each `data.type` to as many destinations as you need.

### Example payloads that don't match the list

**The Problem:** Routing on the documented subscription names misses some deliveries.

**Why It Happens:** Northpass's examples use `learner_created_events` for an invitation and `learner_enrolled_to_learning_path_events` for a learning-path enrolment. Several other examples contain comments or invalid JSON.

**Workarounds:**

* Match both learning-path spellings and log unknown types.
* Build fixtures from real deliveries rather than the docs.

**How Hookdeck Can Help:** Hookdeck stores each delivery's real body, so you can see which `data.type` values your school actually sends.

### Reachability check on save

**The Problem:** You can't create a subscription before the receiver is deployed.

**Why It Happens:** Northpass requires the URL to return `2xx` when the subscription is saved.

**Workarounds:**

* Deploy a minimal endpoint that returns `200` first.

**How Hookdeck Can Help:** A Hookdeck source URL answers `200` from the moment it's created, so subscriptions can be set up before your service exists.

## Best practices

### Compare the base64 string carefully

Decode and compare bytes, or compare the exact string Northpass sends after validating its format. Base64 values with stray trailing bits can decode to the same bytes, so the verifier above checks that the value round-trips.

### Keep the key as text

The shared secret is used as UTF-8 bytes. Don't base64- or hex-decode it, even if it looks encoded.

### Acknowledge, then call the CRM

Northpass doesn't state a timeout, but CRM and HRIS calls are slow. Store the event and return `2xx`, then process in the background as the [asynchronous processing guide](/webhooks/guides/why-implement-asynchronous-processing-webhooks) describes.

### Record `data.id` with each change

Hourly retries over a day mean the same event can arrive many times. Keep `data.id` alongside the update, per our [idempotency guide](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

Northpass webhooks are well suited to syncing training activity into customer systems, with a familiar Shopify-style signature and an event ID on every delivery. The 10-subscription cap, mismatched examples and the rule that disables every webhook after 24 hours of failures are what to design around.

[Hookdeck Event Gateway](https://hookdeck.com) takes the pressure off that 24-hour rule and lets one subscription feed many consumers.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Northpass webhooks reliably in minutes.