Skip to content

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.

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 data can gain new fields.

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 standardwebhooks
from 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 standardwebhooks
import { 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-webhooks
use 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 after whsec_ and compare the result in constant time (for example with hmac.compare_digest). Also reject timestamps that deviate from the current time by more than a few minutes.
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.

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:

Terminal window
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.

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.

Delivery attempts can overlap in rare cases, and the order of deliveries is not guaranteed. Process events idempotently:

  • id (same as webhook-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_at is UTC with microseconds and sorts lexicographically: use it to put events back into their actual order.

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.

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.

  • 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.