Developer docs

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:

TypeTrigger
training.completedA training session ended
evaluation.completedAn evaluation was scored
assignment.createdAn assignment was created
assignment.completedAn assignment was completed
member.joinedA membership started
member.removedA membership ended
crm.sync.failedA CRM sync attempt failed
pingonly 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"]);
}
Rotating the secret: there is no overlap rotation for webhook secrets (unlike key rotation). Create a new endpoint with the same events, switch your receiving side over to its secret, then delete the old endpoint.

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.