Organization webhooks push events to your HTTPS endpoint as they happen, so you can integrate Xentree in real time instead of polling.Overview#
Webhooks belong to an organization and are managed by organization administrators.
Creating a webhook requires an active Team (or Enterprise) plan. Without it, create, update (other than disabling), rotate-secret, and test-ping return 403 with error: "feature_not_in_plan". Listing, reading, disabling, deleting, and viewing deliveries stay available after a downgrade.
Limits: 20 webhooks per organization and 256 KB per event payload. A 21st webhook returns 409.
Each webhook subscribes to one or more of the 30 event types. One URL can be registered once per organization (duplicates return 409). Endpoints must be public HTTPS URLs. Private, loopback, link-local, and otherwise internal addresses are rejected (400 on create/update, and blocked again at delivery time). Redirects are never followed.
Respond with any 2xx within 10 seconds (5 seconds to connect). Response bodies are stored truncated to 2 KB for the delivery log.
Managing webhooks#
All routes live under /api/organizations/{organization_id}/webhooks and accept a bearer token or an API key belonging to an organization administrator.| Method | Path | Result |
|---|
| POST | /webhooks | 201. Returns the webhook and its signing secret, shown only once |
| GET | /webhooks | 200. List webhooks |
| GET | /webhooks/{webhook_id} | 200. Get one webhook |
| PATCH | /webhooks/{webhook_id} | 200. Update url, events, or status (active / disabled) |
| DELETE | /webhooks/{webhook_id} | 204 |
| POST | /webhooks/{webhook_id}/rotate-secret | 200. New secret, old one stays valid for 24 hours |
| POST | /webhooks/{webhook_id}/test | 202. Queues a webhook.test.ping delivery (409 if the webhook is disabled) |
| GET | /webhooks/{webhook_id}/deliveries | 200. Query: limit (1–100, default 25), cursor, status |
| GET | /webhooks/{webhook_id}/deliveries/{delivery_id} | 200. Includes the payload as sent (null once redacted) |
{
"url": "https://example.com/hooks/xentree",
"events": ["xel.module.completed", "organization.member.added"]
}
Example response (secret only appears here):{
"id": "…",
"organization_id": "…",
"url": "https://example.com/hooks/xentree",
"events": ["organization.member.added", "xel.module.completed"],
"status": "active",
"disabled_reason": null,
"consecutive_failures": 0,
"secret_masked": "whsec_...a1b2",
"previous_secret_valid_until": null,
"created_at": "2026-10-13T10:00:00Z",
"updated_at": "2026-10-13T10:00:00Z",
"secret": "whsec_<64 hex characters>"
}
Unknown event names return 400 with the list of supported events. Store the secret securely; it cannot be retrieved later, only rotated.Event envelope#
Every delivery is a POST with a JSON body:{
"id": "evt_3f9c0d2e7a1b4c5d8e6f1a2b3c4d5e6f",
"event": "xel.module.completed",
"organization_id": "org_…",
"created_at": "2026-10-13T10:15:30.123456Z",
"data": { }
}
| Header | Meaning |
|---|
X-Xentree-Event | Event name |
X-Xentree-Delivery | Delivery ID (one per delivery, same across retries) |
X-Xentree-Idempotency-Key | Stable key; use it to de-duplicate retries |
X-Xentree-Key-Id | ID of the secret used for signing |
X-Xentree-Signature | Signature, see below |
Credential-like fields are stripped from data before sending. Treat deliveries as at-least-once: de-duplicate on id or the idempotency key.Verifying signatures#
Always verify the signature against the raw request body bytes, before parsing JSON.X-Xentree-Signature: t=<timestamp>,v1=<signature>
t is a Unix timestamp in seconds.
v1 is the lowercase hex HMAC-SHA256, keyed with your whsec_… secret, of the string "<timestamp>.<raw_body>".
Reject the request if t is more than 300 seconds from your clock (replay protection).
Compare signatures in constant time.
Zero-downtime secret rotation#
After rotate-secret, the old secret stays valid for 24 hours. During that window each delivery carries two v1 values, the new secret first and the old one second:X-Xentree-Signature: t=1760350530,v1=<signed with new secret>,v1=<signed with old secret>
Accept the request if any v1 matches your secret. Deploy the new secret to your receiver at any point in the window; nothing is dropped. The previous_secret_valid_until field on the webhook shows when the old secret expires.Connect partners#
Webhooks delivered to Xentree Connect partners keep their original header format for backwards compatibility:X-Xentree-Timestamp: <timestamp>
X-Xentree-Signature: v1=<hmac>, computed over "<timestamp>.<delivery_id>.<raw_body>" where delivery_id is X-Xentree-Delivery
X-Xentree-Signature-V2: the standard t=<timestamp>,v1=<signature> format described above. New partner integrations should verify this header.
Python#
Flask usage: verify_xentree_signature(request.get_data(), request.headers["X-Xentree-Signature"], SECRET).Node.js / TypeScript#
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verifyXentreeSignature(
rawBody: Buffer,
header: string,
secret: string,
): boolean {
let timestamp: number | null = null;
const candidates: string[] = [];
for (const part of header.split(",")) {
const idx = part.indexOf("=");
if (idx < 0) return false;
const key = part.slice(0, idx).trim();
const value = part.slice(idx + 1).trim();
if (key === "t" && timestamp === null) {
timestamp = Number.parseInt(value, 10);
if (Number.isNaN(timestamp)) return false;
} else if (key === "v1") {
candidates.push(value);
}
}
if (timestamp === null || candidates.length === 0) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest("hex");
const expectedBuf = Buffer.from(expected);
let matched = false;
for (const candidate of candidates) {
const buf = Buffer.from(candidate);
if (buf.length === expectedBuf.length && timingSafeEqual(buf, expectedBuf)) {
matched = true; // keep looping so timing does not reveal which one matched
}
}
return matched;
}
Express usage: mount express.raw({ type: "application/json" }) on the webhook route so req.body is the unmodified Buffer.Event catalog#
30 events in 9 categories. Active events are emitted today. Roadmap events are registered and can be subscribed to, but nothing emits them yet. Subscribing now means they start arriving without any change on your side once released.Xentree English (XEL) & Learning#
| Event | Status | Description |
|---|
xel.module.started | Active | A member started a XEL module |
xel.module.completed | Active | A member completed a XEL module |
xel.task.submitted | Active | A learning task was submitted |
xel.evaluation.completed | Active | AI finished evaluating a speaking or writing task |
xel.session.ended | Active | A XEL learning session ended |
xel.xpi.updated | Roadmap | A member's proficiency index (XPI) changed |
Live Sessions & Transcripts#
| Event | Status | Description |
|---|
live.session.started | Active | A live voice or avatar session started |
live.session.ended | Active | A live session finished |
live.transcript.saved | Active | A live session transcript was saved |
live.quota.warning | Roadmap | Remaining live quota reached the warning threshold |
Gamification & Progress#
| Event | Status | Description |
|---|
gamification.level_up | Active | A member reached a new level |
gamification.streak.milestone | Active | A member hit a 7 or 30 day streak multiple |
gamification.xp_earned | Roadmap | A member earned XP |
gamification.quest.completed | Roadmap | A daily quest was completed |
Conversations & AI Notes#
| Event | Status | Description |
|---|
conversation.created | Roadmap | A conversation was created |
conversation.session.ended | Roadmap | An AI chat session ended |
conversation.note.created | Roadmap | A note was created from a conversation |
Knowledge Base (RAG)#
| Event | Status | Description |
|---|
knowledge.document.uploaded | Roadmap | A document was uploaded to the knowledge base |
knowledge.document.indexed | Roadmap | A document finished indexing and is queryable |
Organization & Members#
| Event | Status | Description |
|---|
organization.member.added | Active | A member was added to the organization |
organization.member.removed | Active | A member was removed from the organization |
organization.member.role_updated | Active | A member's role changed |
organization.custom_domain.status_changed | Roadmap | Custom domain DNS/SSL status changed |
Admin Stats & Objectives#
| Event | Status | Description |
|---|
stats.objective.created | Roadmap | A learning objective (OKR) was created |
stats.objective.archived | Roadmap | A learning objective was archived |
stats.target.updated | Roadmap | A KPI/impact target was updated |
Security & Integrations#
| Event | Status | Description |
|---|
api_key.created | Active | An API key was created |
api_key.deleted | Active | An API key was revoked |
integration.token.revoked | Roadmap | An integration grant token was revoked |
System#
| Event | Status | Description |
|---|
webhook.test.ping | Active | Sent by the /test endpoint |
Delivery, retries, and circuit breaker#
Your endpoint's response decides what happens next:| Response | Outcome |
|---|
2xx or 409 | Delivered. Failure counter resets |
408, 429, 5xx, network error, timeout | Retried |
410 Gone | Delivery marked dead and the webhook is disabled immediately |
Other 4xx, 3xx | Permanent failure, not retried |
Retry schedule. Retries use full jitter: each wait is random between zero and a ceiling that grows per attempt: 1 min, 5 min, 30 min, 2 h, 6 h, then stays at 6 hours. A delivery gets up to 12 attempts before it is marked dead.Circuit breaker. Failures are counted per webhook, and any success resets the counter.| Consecutive failures | Webhook status |
|---|
| 10 | degraded. Still delivering; returns to active after a success |
| 50 | disabled. Delivery stops and disabled_reason explains why |
A 410 response disables the webhook at once, regardless of the counter. To resume, fix your endpoint and PATCH the webhook with "status": "active", then send a test ping.Delivery statuses you may see in the log: pending, leased, retrying, delivered, dead, cancelled.Best practices#
Return 2xx quickly and process asynchronously.
Verify the signature on the raw body and enforce the 300-second window.
De-duplicate on the event id or X-Xentree-Idempotency-Key.
Subscribe only to the events you need.
Rotate secrets periodically and use the 24-hour overlap for zero-downtime changes.
Modified at 2026-10-13 16:09:40