Webhooks
Webhooks notify your systems about events for users and participations without you
having to poll the API: Schulungstool sends signed POST requests to an HTTPS URL you
choose. You set up webhook endpoints in your admin area under Settings > Advanced
settings > Developer options > Webhooks.
All event types with payload schema and examples are listed in the API reference, section Webhooks.
Receiving events
Section titled “Receiving events”Every event arrives at your endpoint as a POST request with a JSON body. The envelope
is the same for all event types:
{ "id": "msg_7f3c0a1b2c3d4e5f60718293a4b5c6d7", "type": "participation.completed", "occurred_at": "2026-10-17T14:00:00.123456Z", "api_version": "v1", "tenant": "ihre-firma.schulungstool.de", "tenant_id": "018c2a4e-9a6c-4e0d-8b8f-4c5d6e7f8a9b", "data": { "id": "018c2a4e-6d3f-4b7a-9e5c-1f2a3b4c5d6e", "user": "018c2a4e-7e4a-4c8b-8f6d-2a3b4c5d6e7f", "course": "018c2a4e-8f5b-4d9c-9a7e-3b4c5d6e7f8a" }}Respond with any 2xx status as soon as you have stored the event. Any other response, including a redirect, counts as a failure and is retried. Respond quickly: after 10 seconds the delivery counts as failed. Process expensive logic asynchronously, for example via a queue, and acknowledge receipt right away.
Two rules make your integration future-proof:
- Ignore unknown event types (acknowledge them with 2xx): new types can be added at any time.
- Ignore unknown fields: the envelope and
datacan gain new fields.
Verifying signatures
Section titled “Verifying signatures”Every delivery is signed according to the Standard Webhooks specification and carries three headers:
| Header | Meaning |
|---|---|
webhook-id |
Event ID, identical across all delivery attempts. |
webhook-timestamp |
Unix timestamp (seconds) of this delivery attempt. |
webhook-signature |
Signature(s), e.g. v1,K5oZfzN9.... During a secret rotation the header contains multiple signatures separated by spaces; one valid signature is enough. |
You see the signing secret (whsec_...) when creating the endpoint and at any time via
Show signing secret. Verify the signature with one of the Standard Webhooks libraries
before processing an event:
# pip install standardwebhooksfrom standardwebhooks.webhooks import Webhook, WebhookVerificationError
webhook = Webhook("whsec_...")
def handle(raw_body: bytes, headers: dict): try: payload = webhook.verify(raw_body, headers) except WebhookVerificationError: return 400 # payload is the verified event return 204// npm install standardwebhooksimport { Webhook } from "standardwebhooks";
const webhook = new Webhook("whsec_...");
function handle(rawBody: string, headers: Record<string, string>): number { try { webhook.verify(rawBody, headers); } catch { return 400; } return 204;}// composer require standard-webhooks/standard-webhooksuse StandardWebhooks\Webhook;
$webhook = new Webhook("whsec_...");try { $webhook->verify($rawBody, $headers);} catch (\Exception $e) { http_response_code(400); exit;}Two pitfalls:
- The raw request body is what gets signed. Verify the exact bytes that arrive, not re-serialized JSON: a reordered field or an extra space already invalidates the signature.
- A hand-rolled check needs constant-time comparison. If you verify without a
library, compute HMAC-SHA256 over
{webhook-id}.{webhook-timestamp}.{body}with the Base64-decoded part of the secret afterwhsec_and compare the result in constant time (for example withhmac.compare_digest). Also reject timestamps that deviate from the current time by more than a few minutes.
Event types
Section titled “Event types”| Event type | Sent when … |
|---|---|
participation.created |
a participation is created (including recurring trainings, import or API). |
participation.completed |
a participation is completed, no matter by whom. |
participation.certificate_created |
the certificate for a completed participation has been generated. |
participation.deleted |
a participation is moved to the trash. |
user.created |
a user account is created (including import, self-registration or API). |
user.activated |
a deactivated user account is activated again. |
user.deactivated |
a user account is deactivated. |
user.deleted |
a user account is moved to the trash. |
ping |
you send a test event in the developer options. data is always empty. |
Each endpoint only receives the event types it has subscribed to. Schemas and examples: API reference, section Webhooks.
Fetching the current state
Section titled “Fetching the current state”Events deliberately carry only IDs (plus the deletion time for *.deleted), not full
objects. That keeps personal data snapshots out of your receiving log, and you never
process stale intermediate states. Fetch the current state with your API key whenever
you need it:
curl "https://api.schulungstool.de/v1/participations/018c2a4e-6d3f-4b7a-9e5c-1f2a3b4c5d6e/" \ -H "Authorization: Token <API key>"Expect 404 here: the object may have been deleted between the event and your request.
In that case a dedicated *.deleted event follows.
Retries and automatic deactivation
Section titled “Retries and automatic deactivation”If a delivery fails, Schulungstool retries it with growing delays: after about
1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours and then every 10 hours, up to
8 attempts in total. If your endpoint responds with 429 and a Retry-After header
(seconds), the next attempt is postponed accordingly.
Two special cases deactivate the endpoint automatically:
- Persistent failures: if all deliveries to an endpoint fail for five days in a row, it is deactivated and the administrators are notified by email.
- HTTP 410 (Gone): if the endpoint responds with
410, it is deactivated immediately and without an email. Use this to cleanly unregister a receiver that has been shut down.
Events that occur while an endpoint is deactivated are not delivered later. After reactivating, you can re-trigger individual deliveries from the delivery log.
Idempotency and ordering
Section titled “Idempotency and ordering”Delivery attempts can overlap in rare cases, and the order of deliveries is not guaranteed. Process events idempotently:
id(same aswebhook-id) is identical for every delivery attempt of the same event. If you have already processed an ID, acknowledge with 2xx and do nothing else.occurred_atis UTC with microseconds and sorts lexicographically: use it to put events back into their actual order.
Sending test events
Section titled “Sending test events”Under Developer options > Webhooks, use an endpoint’s three-dot menu to send a test
event, either as ping or as any event type with fixed example data. The result,
including your endpoint’s response, appears immediately, and the delivery shows up in
the delivery log like any other.
Rotating the signing secret
Section titled “Rotating the signing secret”Rotate signing secret generates a new secret. The previous one stays valid for
24 hours; during that time the webhook-signature header carries both signatures, so
you can switch your receiver over without missing any events. The Standard Webhooks
libraries automatically accept any valid signature in the header.
Troubleshooting
Section titled “Troubleshooting”- Delivery log: every endpoint has a log of the last 30 days with request, response, status code and error text of each delivery attempt, filterable by status, event type and time range. You can re-trigger individual deliveries there.
- Invalid signature: check that you verify the raw body (see above) and that no old secret is still configured after a rotation.
- No events? Check that the endpoint is active and subscribed to the event type, and send a test event. Your endpoint must be a public HTTPS URL on port 443.
- The API’s error structure for fetching the current state is described in Errors and rate limiting.