Naar inhoud
Inloggen
Docs/Koppelingen

Webhooks

Met webhooks houden we je eigen systemen op de hoogte. Zodra er iets in je organisatie gebeurt, sturen we een bericht naar een adres dat jij kiest. Geen periodiek bevragen, geen nachtelijke export: iemand wordt toegevoegd, rondt een cursus af, en je HR-systeem weet het seconden later.

Endpoints beheer je onder Organisatie, Webhooks. Deze pagina legt uit wat we versturen en vooral hoe je controleert dat een bericht echt van ons komt.

Een endpoint toevoegen

Ga naar Organisatie, Webhooks en kies Webhook toevoegen. Je geeft ons drie dingen.

  • URL: het adres waar we naartoe sturen. Het moet https zijn en bereikbaar vanaf het internet.
  • Naam: voor je eigen overzicht, bijvoorbeeld "HR-koppeling".
  • Gebeurtenissen: waar je over wilt horen. Kies er minstens een.

Als je opslaat tonen we een ondertekeningssleutel. Dit is de enige keer dat we hem kunnen laten zien, dus kopieer hem voordat je het venster sluit. Wij bewaren zelf geen kopie.

Waarschuwing

Bewaar de sleutel zoals je een wachtwoord bewaart: in je secret manager of je omgevingsvariabelen, nooit in je broncode.

Wat we niet accepteren

We weigeren adressen waarmee een webhook een intern netwerk zou kunnen bereiken.

  • http-adressen. Alleen https.
  • Adressen met een gebruikersnaam of wachtwoord erin.
  • Een andere poort dan de standaardpoort.

We volgen ook geen redirects. Registreer het uiteindelijke adres; een 3xx telt als een mislukte aflevering.

Hoe een aflevering eruitziet

Elke aflevering is een POST met dezelfde envelop. Alleen data verschilt per soort gebeurtenis.

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

Daarnaast sturen we deze headers mee.

HeaderBetekenis
Content-TypeAltijd application/json
User-AgentZunderwork-Webhooks/1
X-Zunderwork-EventHet soort gebeurtenis, gelijk aan type in de body
X-Zunderwork-DeliveryHet afleveringsnummer, gelijk aan id in de body
X-Zunderwork-SignatureDe handtekening, hieronder uitgelegd

Antwoord met een 2xx om te bevestigen dat je het bericht hebt ontvangen. Antwoord snel: na tien seconden geven we het op. Heb je echt werk te doen, zet het dan eerst op je eigen wachtrij en bevestig meteen.

Een binnenkomende webhook valideren

Je endpoint is een openbaar adres. Iedereen die het vindt kan er berichten naartoe sturen, dus voordat je een bericht vertrouwt moet je vaststellen dat het van ons komt. Daar is de sleutel voor.

Elke aflevering heeft een handtekening-header:

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

t is het moment waarop we ondertekend hebben, als Unix-tijdstempel. v1 is een HMAC-SHA256 over de tekst {t}.{ruwe request body}, met jouw ondertekeningssleutel als sleutel. Je maakt die hash aan jouw kant opnieuw en vergelijkt.

Twee details bepalen of dit werkt.

  1. Gebruik de ruwe request body, precies zoals hij binnenkwam, voordat je JSON parseert. Parseer je en zet je het daarna weer om, dan veranderen de bytes en klopt de handtekening nooit.
  2. Controleer hoe oud t is en weiger alles ouder dan ongeveer vijf minuten. Het tijdstempel zit juist daarvoor in de ondertekende tekst: zonder die controle kan iemand die een aflevering onderschept die eindeloos bij je blijven herhalen.

Vergelijk de twee hashes met een timing-veilige functie, hmac.compare_digest of hash_equals, niet met ==.

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

De meeste frameworks geven je standaard een geparseerde body. Zorg dat je in plaats daarvan de ruwe body uitleest.

De sleutel roteren

Je kunt een sleutel op elk moment vervangen op de pagina van de webhook, onder Ondertekeningssleutel. Doe dat als de oude is uitgelekt, als iemand met toegang vertrekt, of gewoon periodiek.

Roteren werkt direct en er is geen overlapperiode. De oude sleutel stopt met werken op het moment dat je bevestigt, en tot je ontvanger de nieuwe gebruikt, faalt elke aflevering jouw eigen handtekeningcontrole. Roteer dus op het moment dat je de nieuwe sleutel kunt uitrollen, en houd de nieuwe waarde bij de hand: net als de eerste tonen we hem eenmalig en kunnen we hem daarna niet nog eens laten zien.

Nieuwe pogingen, en wanneer we stoppen

Een mislukte aflevering proberen we tot vijf keer opnieuw, met groeiende tussenpozen: ongeveer 10 seconden, 40 seconden, 2 minuten, 10 minuten, 45 minuten.

Jouw antwoordWat wij doen
2xxGelukt. De foutteller gaat terug naar nul.
408, 429, 5xx, een timeout, een verbindings- of TLS-foutWe proberen het opnieuw volgens bovenstaand schema.
Elke andere 4xxWe proberen het niet opnieuw. Je hebt het verzoek begrepen en nee gezegd; een 404 wordt bij de derde poging geen 200.
3xxTelt als mislukt. We volgen geen redirects.

Na 30 mislukte pogingen op rij zetten we het endpoint uit en stoppen we met versturen. Je ziet dat op de pagina van de webhook, met de laatste fout die we kregen. Weer aanzetten wist ook de foutteller.

Fouten vervallen: gaan er meer dan 20 uur voorbij zonder fout, dan begint de volgende fout weer bij een. Een endpoint dat een keer per dag hapert, zet zichzelf dus nooit uit.

Opmerking

We bewaren bewust geen afleveringsgeschiedenis, dus we kunnen niet vertellen wat we vorige week dinsdag hebben gestuurd. Log afleveringen aan jouw kant, op X-Zunderwork-Delivery. Dat nummer is ook hoe je dubbele berichten herkent: een nieuwe poging gebruikt hetzelfde nummer, dus behandel een herhaald nummer als dezelfde gebeurtenis.

Waarop je je kunt abonneren

Personen

GebeurtenisWanneer die afgaat
organization_user.joinedIemand heeft een uitnodiging geaccepteerd en is lid geworden
organization_user.updatedDe rol, gegevens of actieve status van een lid is gewijzigd
organization_user.deactivatedEen lid is niet langer actief
{
  "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 het lidmaatschap. member.userId is de persoon, die bij meer dan een organisatie kan horen.

reason komt alleen voor bij organization_user.deactivated en is disabled (het lidmaatschap is uitgezet) of deleted (het lidmaatschap is verwijderd).

Cursussen

course.created, course.updated en course.deleted.

{
  "course": {
    "id": 101,
    "name": "Veilig werken op hoogte",
    "description": "Jaarlijkse opfriscursus",
    "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": "Buitendienst" }]
  }
}

Een cursus archiveren komt binnen als course.updated met een gevulde archivedAt, niet als een verwijdering.

Voortgang van deelnemers

course_progress.started, course_progress.lesson_completed, course_progress.completed en course_progress.all_completed.

Ze delen dezelfde vorm. De objecten course en member zijn identiek aan die hierboven, dus een mapper per entiteit dekt alles.

{
  "progress": {
    "id": 9001,
    "startedAt": "2026-08-29T10:00:00+00:00",
    "finishedAt": "2026-08-29T13:45:00+00:00"
  },
  "course": { "id": 101, "name": "Veilig werken op hoogte" },
  "member": { "id": 812, "email": "[email protected]" }
}

progress.finishedAt is leeg tot de cursus is afgerond, dus leeg bij started en bij lesson_completed. Bij lesson_completed komt er een lesson-object bij:

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

Vier dingen om te weten over voortgang.

  • Een preview levert nooit een gebeurtenis op. Een manager die een cursus bekijkt is geen deelnemer die er een start.
  • all_completed komt naast completed. Was de zojuist afgeronde cursus de laatste openstaande, dan komen beide binnen, als aparte afleveringen.
  • all_completed kan vaker afgaan. Het zegt "op dit moment staat er niets meer open". Wijs later een nieuwe cursus toe en het gaat opnieuw af zodra die klaar is. Houd er rekening mee dat je het vaker krijgt.
  • De volgorde ligt niet vast. Elke gebeurtenis staat apart in de wachtrij en wordt apart opnieuw geprobeerd, dus een herhaalde gebeurtenis kan na een latere binnenkomen. Gebruik createdAt als je ze op volgorde nodig hebt.

"Openstaand" betekent de cursussen die iemand in zijn eigen lijst ziet: aangezet, niet gearchiveerd, voorbij de startdatum en aan hem getagd. Een cursus waarvan de voorwaarden nog niet zijn behaald, telt nog steeds als openstaand.

Meebewegen met wijzigingen

We voegen dingen toe zonder aankondiging en we halen niets weg.

  • Een veld dat bestaat verdwijnt niet, krijgt geen andere naam en verandert niet van type.
  • De betekenis van een veld verandert niet.
  • Er kunnen op elk moment nieuwe velden bij komen, en nieuwe soorten gebeurtenissen ook.

Bouw je ontvanger dus zo dat hij velden en gebeurtenissen die hij niet kent negeert, en ga niet uit van de volgorde van velden. Een ontvanger die op iets onbekends afhaakt, breekt bij een gewone release.

Moeten we ooit toch iets breken, dan wijzigen we geen bestaande gebeurtenis. Dan komt er een nieuwe bij, sturen we een tijd lang allebei, laten we het weten aan de organisaties die zich hebben geabonneerd, en pas daarna stoppen we met de oude.

Op deze pagina