Skip to content
Sign in
Docs/Integrations

Webhooks

With webhooks we keep your own systems up to date. As soon as something happens in your organization, we post a message to an address you choose. No polling, no nightly export: someone joins, finishes a course, and your HR system knows about it seconds later.

You manage endpoints under Organization, Webhooks. This page explains what we send and, most importantly, how to check that a message really came from us.

Adding an endpoint

Open Organization, Webhooks and choose Add webhook. You give us three things.

  • URL: the address we post to. It must be https and reachable from the internet.
  • Name: for your own overview, for example "HR sync".
  • Events: what you want to hear about. Pick at least one.

When you save, we show you a signing secret. This is the only time we can show it, so copy it before you close the window. We do not keep a copy of it ourselves.

Warning

Store the secret the way you store a password: in your secret manager or your environment variables, never in your source code.

What we will not accept

We refuse addresses that would let a webhook reach into a private network.

  • http addresses. Only https.
  • Addresses with a username or password in them.
  • A port other than the default for the scheme.

We also do not follow redirects. Register the final address; a 3xx response counts as a failed delivery.

What a delivery looks like

Every delivery is a POST with the same envelope. Only data differs per event type.

{
  "id": "3f2b9c14-0d51-4a7e-9a1b-8c2d6f0e4b77",
  "type": "course_progress.completed",
  "createdAt": "2026-08-29T13:45:02+00:00",
  "organizationId": 42,
  "data": { }
}

Alongside it we send these headers.

HeaderMeaning
Content-TypeAlways application/json
User-AgentZunderwork-Webhooks/1
X-Zunderwork-EventThe event type, the same as type in the body
X-Zunderwork-DeliveryThe delivery id, the same as id in the body
X-Zunderwork-SignatureThe signature, explained below

Answer with any 2xx to confirm you received it. Answer quickly: we give up after ten seconds. If there is real work to do, put it on your own queue first and confirm straight away.

Validating an incoming webhook

Your endpoint is a public address. Anyone who finds it can post to it, so before you trust a message you have to prove it came from us. That is what the secret is for.

Every delivery carries a signature header:

X-Zunderwork-Signature: t=1787925523,v1=c3044dcc4c74f78b530bc4c8a3bac4f65e04fb6…

t is the moment we signed, as a Unix timestamp. v1 is an HMAC-SHA256 over the string {t}.{raw request body}, keyed with your signing secret. You recreate that hash on your side and compare.

Two details decide whether this works.

  1. Use the raw request body, exactly as it arrived, before any JSON parsing. If you parse and re-encode, the bytes change and the signature will never match.
  2. Check how old t is and refuse anything older than about five minutes. The timestamp sits inside the signed string for exactly this reason: without that check, someone who captures one delivery can replay it at you forever.

Compare the two hashes with a timing-safe function, hmac.compare_digest or hash_equals, not with ==.

import hmac
import hashlib
import time

def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    timestamp, signature = parts["t"], parts["v1"]

    if abs(time.time() - int(timestamp)) > tolerance:
        return False

    expected = hmac.new(
        secret.encode(),
        f"{timestamp}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected, signature)

Tip

Most frameworks give you a parsed body by default. Make sure you read the raw one instead.

Rotating the secret

You can replace a secret at any time from the webhook's page, under Signing secret. Use it when the old one has leaked, when someone with access to it leaves, or simply on a schedule.

Rotation takes effect immediately and there is no overlap period. The old secret stops working the moment you confirm, and until your receiver uses the new one, every delivery fails your own signature check. So rotate at the moment you can deploy the new secret, and keep the new value in hand: like the first one, we show it once and cannot show it again.

Retries, and when we stop

A delivery that fails is retried up to five times with growing gaps: roughly 10 seconds, 40 seconds, 2 minutes, 10 minutes, 45 minutes.

Your responseWhat we do
2xxSuccess. The failure count resets to zero.
408, 429, 5xx, a timeout, a connection or TLS errorWe retry on the schedule above.
Any other 4xxWe do not retry. You understood the request and said no; a 404 will not become a 200 on the third attempt.
3xxCounts as a failure. We do not follow redirects.

After 30 failed attempts in a row we switch the endpoint off and stop sending. You will see this on the webhook's page, with the last error we received. Switching it back on also clears the failure count.

Failures decay: if more than 20 hours pass without one, the next failure starts a fresh count at one. An endpoint that hiccups once a day never switches itself off.

Note

We deliberately keep no delivery history, so we cannot tell you what we sent last Tuesday. Log deliveries on your side, keyed by X-Zunderwork-Delivery. That id is also how you deduplicate: a retry reuses it, so treat a repeated id as the same event.

Which events you can subscribe to

People

EventWhen it fires
organization_user.joinedSomeone accepted an invite and became a member
organization_user.updatedA member's role, profile or active state changed
organization_user.deactivatedA member is no longer active
{
  "member": {
    "id": 812,
    "userId": 5501,
    "email": "[email protected]",
    "firstName": "Sam",
    "lastName": "de Vries",
    "role": "member",
    "enabled": false,
    "createdAt": "2026-03-01T09:12:44+00:00",
    "updatedAt": "2026-08-29T13:44:02+00:00",
    "verifiedAt": "2026-03-01T09:20:10+00:00",
    "startsAt": null
  },
  "reason": "disabled"
}

member.id is the membership. member.userId is the person, who can belong to more than one organization.

reason appears only on organization_user.deactivated and is either disabled (the membership was switched off) or deleted (the membership was removed).

Courses

course.created, course.updated and course.deleted.

{
  "course": {
    "id": 101,
    "name": "Working safely at height",
    "description": "Annual refresher",
    "enabled": true,
    "createdAt": "2026-07-02T15:35:15+00:00",
    "updatedAt": "2026-08-29T11:02:00+00:00",
    "availableAt": null,
    "archivedAt": null,
    "tags": [{ "id": 101, "name": "Field staff" }]
  }
}

Archiving a course arrives as course.updated with archivedAt filled in, not as a delete.

Learner progress

course_progress.started, course_progress.lesson_completed, course_progress.completed and course_progress.all_completed.

They share one shape. The course and member objects are identical to the ones above, so one mapper per entity covers everything.

{
  "progress": {
    "id": 9001,
    "startedAt": "2026-08-29T10:00:00+00:00",
    "finishedAt": "2026-08-29T13:45:00+00:00"
  },
  "course": { "id": 101, "name": "Working safely at height" },
  "member": { "id": 812, "email": "[email protected]" }
}

progress.finishedAt is empty until the course is finished, so it is empty on started and on lesson_completed. A lesson_completed event adds a lesson object:

"lesson": { "id": 55, "name": "Harness checks", "completedLessons": 3, "totalLessons": 7 }

Four things to know about progress events.

  • A preview never fires one. A manager previewing a course is not a learner starting one.
  • all_completed arrives next to completed. If the course someone just finished was their last outstanding one, both arrive, as separate deliveries.
  • all_completed can fire more than once. It says "nothing is outstanding as of now". Assign a new course later and it fires again once that is finished. Handle it more than once.
  • Order is not guaranteed. Every event is queued and retried on its own, so a retried event can arrive after a later one. Use createdAt if you need to put them in order.

"Outstanding" means the courses that person sees in their own list: enabled, not archived, past their start date, and tagged for them. A course whose prerequisites are not met yet still counts as outstanding.

Keeping up with changes

We add things without warning and we do not take things away.

  • A field that exists will not be removed, renamed, or change type.
  • What a field means will not change.
  • New fields can appear at any time, and new event types too.

So build your receiver to ignore fields and event types it does not recognise, and do not depend on the order of fields. A receiver that rejects anything unfamiliar will break on an ordinary release.

If we ever need a genuinely breaking change, we will not change an existing event type. We will add a new one, send both for a while, tell the organizations that are subscribed, and only then stop sending the old one.

On this page