# Guide to Teachfloor Webhooks: Features and Best Practices

Teachfloor is a cohort-based learning platform for running courses, programmes and academies. Its webhooks tell your systems when people join the organisation or a course, finish an element or a whole course, log in, or when course content changes, so you can provision access, issue certificates or keep a CRM in step with learning activity.

This guide covers Teachfloor's endpoint setup, the event envelope and update diffs, verifying `Teachfloor-Signature`, and planning around a retry window of under two minutes.

## What are Teachfloor webhooks?

A Teachfloor webhook endpoint is an HTTPS URL added under **Developers > Webhooks**, with a list of events it should receive. Each endpoint has its own signing secret. When a selected event happens, Teachfloor sends a `POST` with a JSON body whose `data` block is the object involved, in the same shape the Public API returns.

Events fire for changes from any source: the dashboard, the Public API, automations and the platform itself. That includes writes your own integration makes through the API.

## Teachfloor webhook features

| Feature | Details |
| --- | --- |
| Signature | hex HMAC-SHA256 |
| Signature header | `teachfloor-signature` |
| Body | JSON |
| Event routing | Body field `type` |

From Teachfloor's [delivery and retries page](https://docs.teachfloor.com/docs/webhooks/delivery-retries):

* Any `2xx` within 10 seconds counts as success. Teachfloor recommends `200`.
* A non-`2xx` status, a timeout or a connection failure triggers a retry. There are 3 attempts in total: the first, then retries after 10 seconds and 100 seconds.
* Every delivery carries `Teachfloor-Idempotency-Key`, which equals the envelope `id` and stays the same across retries.
* Delivery history for each endpoint is shown under **Developers > Webhooks**.

## Events and payloads

Every delivery has the same envelope:

| Field | Meaning |
| --- | --- |
| `id` | Delivery ID, stable across retries and mirrored in `Teachfloor-Idempotency-Key` |
| `type` | Event type, for example `course.join`. Route on this |
| `created_at` | ISO 8601 time the event was recorded |
| `installation_id` | Only on endpoints provisioned by an extension app: the installation the event belongs to |
| `data` | The object the event is about. Its `object` field names the shape |

`data.object` is one of `course`, `module`, `element`, `member`, `course_member`, `organization_member` or `activity`. Enumerated values such as `status`, `availability`, `visibility` and element `type` are upper-case, as in the Public API.

### Event Types

| `type` | Event type, for example `course.join`. **Dispatch on this** |
| `created_at` | ISO 8601 timestamp |
| `data` | The object the event is about; its shape depends on `type` |

Teachfloor's [event reference](https://docs.teachfloor.com/docs/webhooks/event-reference) lists more events than the table above:

| `type` | Triggered When |
| --- | --- |
| `organization.join` | A user becomes a member of the organisation (signup, invite, SSO or the Members API) |
| `member.created` | A member joins the organisation, at the same moment as `organization.join` |
| `member.updated` | A member's name, avatar, email or organisation-level custom fields change |
| `member.revoked` | A member is removed from the organisation |
| `xapi.statement.created` | An xAPI statement is recorded |
| `app.installed` / `app.uninstalled` | An organisation installs or uninstalls your extension app |

`member.login`, `course.completed` and `element.completed` carry an activity object rather than the member or course itself, with the course or element in its context.

### Update events carry a diff

Every `*.updated` event (`course.updated`, `module.updated`, `element.updated`, `member.updated`) sends the full current object plus `data.previous_attributes`, holding only the keys that changed and their old values. For `custom_fields`, the old value is the whole previous map.

### Example payload

A `course.join` delivery, from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/teachfloor/latest/course.join.json). Teachfloor's own reference shows `id` as a random string such as `WqX3nKp7Lm2vRt9Z` and upper-case enumerated values, so treat this sample's ID prefix and lower-case values as illustrative:

```json
{
  "id": "evt_abc123",
  "type": "course.join",
  "created_at": "2025-10-07T15:30:00.000000Z",
  "data": {
    "id": "cm_xyz789",
    "object": "course_member",
    "joined_at": "2025-10-07T15:30:00.000000Z",
    "member": {
      "id": "usr_123",
      "object": "member",
      "first_name": "John",
      "last_name": "Doe",
      "full_name": "John Doe",
      "avatar": "https://example.com/avatar.jpg",
      "email": "john@example.com",
      "is_email_verified": true,
      "last_seen": "2025-10-07T14:00:00.000000Z"
    },
    "course": {
      "id": "crs_456",
      "object": "course",
      "created_at": "2025-01-01T00:00:00.000000Z",
      "name": "Introduction to Development",
      "status": "published",
      "cover": "https://example.com/cover.jpg",
      "availability": "public",
      "visibility": "listed",
      "start_date": null,
      "end_date": null,
      "currency": "USD",
      "price": 99.0,
      "free_label": null,
      "url": "https://app.teachfloor.com/org/intro-dev",
      "public_url": "https://app.teachfloor.com/org/intro-dev/join",
      "join_url": "https://app.teachfloor.com/org/intro-dev/join",
      "metadata": {},
      "custom_fields": {}
    },
    "custom_fields": {}
  }
}

```

The `*.created` events point to the object definitions in the reference rather than full examples, so check a real delivery before relying on optional fields.

## Setting up Teachfloor webhooks

1. In Teachfloor, open **Developers > Webhooks** and click **Add Endpoint**.
2. Enter your HTTPS URL, for example `https://example.com/webhooks/teachfloor`.
3. Select the events to receive, then save.
4. Click **Reveal Signing Secret** on the endpoint and store it as `TEACHFLOOR_SIGNING_SECRET`. Use it as a plain string.

To route through Hookdeck, create a Teachfloor source with the endpoint's signing secret and register the source URL as the endpoint. For local development:

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

```

Per endpoint: Developers > Webhooks > endpoint > Reveal Signing Secret.

## Securing Teachfloor webhooks

`Teachfloor-Signature` is the hex HMAC-SHA256 of the raw request body, keyed with the endpoint's signing secret. The whole header value is the digest, with no prefix or version. Teachfloor's [security page](https://docs.teachfloor.com/docs/webhooks/security) shows PHP and Node examples that compare it directly.

Teachfloor's getting-started sample parses the body with `express.json()`. If you verify, read the raw bytes with `express.raw()` instead, because re-serialised JSON won't match the signature.

### Node.js verification

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

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const signature = headers['teachfloor-signature'];
  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) => {
    if (!/^[0-9a-fA-F]{64}$/.test(candidate)) return false;
    const received = Buffer.from(candidate, 'hex');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

The handler deduplicates on `id`, then queues the event for a worker. Invalid JSON or a missing `id` or `type` gets a `400`, and a queue failure a `503` so Teachfloor retries.

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

const app = express();

app.post('/webhooks/teachfloor', express.raw({ type: '*/*' }), async (req, res) => {
  const secret = process.env.TEACHFLOOR_SIGNING_SECRET;
  if (!secret) return res.sendStatus(500);
  if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

  let event;
  try {
    event = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }
  if (!event || typeof event !== 'object' || Array.isArray(event)) return res.sendStatus(400);
  if (typeof event.id !== 'string' || typeof event.type !== 'string') return res.sendStatus(400);

  const data = event.data && typeof event.data === 'object' ? event.data : {};

  try {
    if (await alreadyHandled(event.id)) return res.sendStatus(200);
    await queue.add('teachfloor-event', {
      id: event.id,
      type: event.type,
      object: data.object,
      data,
    });
    await markHandled(event.id);
  } catch {
    return res.sendStatus(503);
  }
  res.sendStatus(200);
});

```

`queue`, `alreadyHandled` and `markHandled` stand for your own job queue and deduplication store.

Hookdeck performs the same check when you use its Teachfloor source type. It returns `200` to Teachfloor and records any mismatch as failed verification. No timestamp is signed, and Hookdeck imposes no time window of its own.

> **Make Teachfloor webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) accepts each Teachfloor delivery and keeps retrying your service on its own schedule, so an outage longer than two minutes doesn't cost you enrolments or completions.

## Teachfloor webhook limitations and pain points

### Retries end after about two minutes

**The Problem:** If your endpoint is down for more than a couple of minutes, events such as `course.completed` are never delivered.

**Why It Happens:** Teachfloor makes 3 attempts in total, 10 seconds and then 100 seconds apart. Whether a failed delivery can be resent from the dashboard isn't documented.

**Workarounds:**

* Check the endpoint's delivery history under **Developers > Webhooks** after any outage.
* Reconcile enrolments and completions through the Public API.

**How Hookdeck Can Help:** Hookdeck accepts the first attempt, stores it, and retries your service for as long as your policy allows, with manual replay for anything left over.

### Your own API writes come back as events

**The Problem:** A sync that writes to Teachfloor through the Public API receives webhooks for its own changes, which can loop.

**Why It Happens:** Teachfloor reports changes from every source, including the API.

**Workarounds:**

* Compare the incoming object with the value you just wrote and skip no-op updates.
* Use `previous_attributes` to act only on fields that matter to you.

**How Hookdeck Can Help:** Hookdeck filters can drop `*.updated` events whose `previous_attributes` hold only fields your service doesn't track.

### A 10-second timeout

**The Problem:** Handlers that call a CRM or certificate service before responding get retried.

**Why It Happens:** Teachfloor treats anything slower than 10 seconds as a failure.

**Workarounds:**

* Queue the event and respond straight away.
* Deduplicate on `Teachfloor-Idempotency-Key`, because a slow success still triggers a retry.

**How Hookdeck Can Help:** Hookdeck responds to Teachfloor at once and lets you set a longer timeout for your own service.

### Some events lack full examples

**The Problem:** Fields for events such as `course.created` or `element.created` have to be read from object definitions.

**Why It Happens:** The event reference gives complete examples for some events and points to the Object Types section for the rest.

**Workarounds:**

* Capture one real delivery per event type before writing parsers.
* Validate optional fields instead of assuming they're present.

**How Hookdeck Can Help:** Hookdeck keeps every delivered body, giving you real payloads to test against.

## Best practices

### Deduplicate before parsing

`Teachfloor-Idempotency-Key` matches `id`, so you can check it from the header before reading the body. The [idempotency guide](/webhooks/guides/implement-webhook-idempotency) covers storage options.

### Route by data.object as well as type

Completion events carry an activity, not the course. Read the course or element from the activity context and fetch more through the Public API when needed.

### Handle upper-case enums

Compare `status`, `availability` and `visibility` as the upper-case values the Public API returns.

### Keep the handler short

Verify, queue and respond, then do the slow work in a worker. See [processing webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Teachfloor sends signed JSON events for course, module, element and member changes, with update diffs in `previous_attributes` and a stable idempotency key. Verify the hex HMAC in `Teachfloor-Signature` against the raw body, respond within 10 seconds, and plan for retries that stop after about two minutes.

[Hookdeck Event Gateway](https://hookdeck.com) verifies Teachfloor requests and holds them until your service has processed them.

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