- Home
- Developers
- Webhooks
Webhooks
Events about your own team's data, signed and delivered to a
URL you control — no polling. Manage them in the app
(Team → Integrations & API → Webhooks) or via the API (scope
webhooks:manage, see the reference).
Events
An endpoint subscribes to a selection of these types when it is created:
| Type | Trigger |
|---|---|
training.completed | A training session ended |
evaluation.completed | An evaluation was scored |
assignment.created | An assignment was created |
assignment.completed | An assignment was completed |
member.joined | A membership started |
member.removed | A membership ended |
crm.sync.failed | A CRM sync attempt failed |
ping | only via "send test event" — not a subscribable type |
assignment.created/assignment.completed
only fire today when the assignment is created or changed through this API itself —
one a team admin creates through the app does not yet raise a webhook event.
Body
Every event shares the same envelope; data differs by type and
carries ids and a summary. A score and user_id are present only when
member_detail_enabled was on at the time of the event — fetch more via
the API if you need it.
{
"id": "evt_7f3c1a2b",
"type": "evaluation.completed",
"created_at": "2026-10-14T08:42:10Z",
"api_version": "2026-10",
"tenant_id": "6a9c3e3e-9b8e-4b7b-9e9d-2b7b1a2c3d4e",
"data": {
"team_id": "3c2b1a0f-5678-4a3b-8c9d-0e1f2a3b4c5d",
"training_id": "7f3cfe1a-1111-4b22-9c33-abcdef012345",
"user_id": "1b9e2b3c-4d5e-6f70-8192-a3b4c5d6e7f8",
"score": 74
}
}Verify the signature
Every delivery carries Sip-Signature: t=<unix time>,v1=<hex>
and, for duplicate detection, Sip-Event-Id. v1 is
HMAC-SHA256(secret, "<t>.<raw body>") — the secret is shown
only once, in the response to POST /webhook-endpoints. Reject timestamps
older than five minutes, and compare in constant time
(hmac.compare_digest or your language's equivalent) — never with
==.
import hashlib
import hmac
import time
def verify(secret: str, header: str, body: bytes, *, max_age_s: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
timestamp, received = int(parts["t"]), parts["v1"]
if abs(time.time() - timestamp) > max_age_s:
return False
message = f"{timestamp}.".encode() + body
expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received)
const crypto = require("crypto");
function verify(secret, header, body, maxAgeS = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const timestamp = parseInt(parts.t, 10);
if (Math.abs(Date.now() / 1000 - timestamp) > maxAgeS) return false;
const message = Buffer.concat([Buffer.from(`${timestamp}.`), body]);
const expected = Buffer.from(
crypto.createHmac("sha256", secret).update(message).digest("hex"));
const received = Buffer.from(parts.v1 || "");
// timingSafeEqual throws (RangeError) on a length mismatch instead of returning false — a
// signature of the wrong length must be rejected BEFORE calling it.
if (received.length !== expected.length) return false;
return crypto.timingSafeEqual(expected, received);
}
<?php
function verify(string $secret, string $header, string $body, int $maxAgeS = 300): bool {
parse_str(str_replace(",", "&", $header), $parts);
$timestamp = (int) $parts["t"];
if (abs(time() - $timestamp) > $maxAgeS) {
return false;
}
$expected = hash_hmac("sha256", "{$timestamp}." . $body, $secret);
return hash_equals($expected, $parts["v1"]);
}
Delivery and retries
Delivered at least once, order not guaranteed; no following of redirects, response
bodies cut off after 1000 characters, 10-second timeout. Any failure (anything other
than a 2xx status) is retried after 1 min, 5 min, 30 min, 2 h,
6 h, 12 h, 24 h (seven attempts, about 45 hours), then
gave_up. If an endpoint goes three days without a single successful
delivery, sip.coach pauses it (disabled_failures) and emails every team
admin; a test event does not restart it automatically.
The target must be HTTPS and publicly reachable — a private or link-local address is rejected both when the endpoint is created and before every delivery attempt.
Managing endpoints
Create, update, delete, send a test event, and retry a failed delivery right away —
from the app or via the API with the webhooks:manage scope. sip.coach logs
every one of these actions regardless of which way it was triggered.
# Trigger a test event (a management key with the webhooks:manage scope):
curl -X POST https://app.sip.coach/api/public/v1/webhook-endpoints/<id>/test \
-H "Authorization: Bearer sipk_live_ab12cd34_…"
Every operation and its parameters are in the reference.