Audit — Webhook subscriptions
3- webhook_subscription.created
- webhook_subscription.updated
- webhook_subscription.deleted
Telos fans out every state change as a webhook event. Subscribers receive a signed POST per matching event with exponential-backoff retries; terminal failures land in a dead-letter queue with Sentry alerts so you never silently lose a delivery.
Subscriptions are managed inside the app at Configurations → Webhooks by leadership and admin users. Each subscription stores a URL, shared secret, and the list of event types you want delivered. A Send test action exercises the full delivery path against your endpoint.
Subscriptions can also be managed programmatically at /api/v1/webhook-subscriptions (list, create, delete) with an API key or OAuth app token carrying webhook scopes. Subscriptions created by an OAuth app are disabled automatically when the org revokes the app's last grant.
Every event uses the same envelope. The payload object varies by event type. The id is unique per event and is safe to use for idempotent processing on your side.
{
"id": "01H8X3F4S0Z9KMQ4N6PYZR7C5A",
"eventType": "opportunity.status_changed",
"entityType": "opportunity",
"entityId": "8c2c9d8e-1234-4abc-9def-1234567890ab",
"payload": {
"previousStatus": "shaping",
"newStatus": "validated",
"title": "Reduce onboarding drop-off"
},
"createdAt": "2026-05-18T14:23:11.842Z"
}Each delivery includes an X-Telos-Signature header of the form t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<rawBody>">, keyed by your subscription secret. Verify before doing anything with the payload: compare in constant time and reject timestamps more than 300 seconds from now, which blocks replay of captured deliveries.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verifyTelosWebhook(
rawBody: string,
signature: string, // "t=<unix seconds>,v1=<hex hmac>"
secret: string,
): boolean {
const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(signature);
if (!match) return false;
const t = Number(match[1]);
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret)
.update(t + "." + rawBody)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(match[2], "hex");
return a.length === b.length && timingSafeEqual(a, b);
}import hmac
import hashlib
import re
import time
TOLERANCE_SECONDS = 300
def verify_telos_webhook(raw_body: bytes, signature: str, secret: str) -> bool:
match = re.fullmatch(r"t=(\d+),v1=([0-9a-f]{64})", signature)
if not match:
return False
t = int(match.group(1))
if abs(time.time() - t) > TOLERANCE_SECONDS:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, match.group(2))Each delivery is attempted up to six times. Non-2xx responses or connection failures trigger a retry on a fixed schedule. The request timeout is 10 seconds. After the sixth failure the delivery is marked dead and you receive a Sentry alert.
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | +30 seconds |
| 3 | +2 minutes |
| 4 | +10 minutes |
| 5 | +1 hour |
| 6 | +6 hours |
| — | Dead-letter (Sentry alert) |
Auto-generated from the source registry. When the team ships a new event type, it appears here without any docs work.
Telos also accepts inbound events. Create a Generic Webhook integration at Configurations → Integrations, then POST JSON to /api/integrations/generic/<integrationId>/webhook. The recommended payload is a batch of typed actions, up to 20 per request, validated against the shared action schemas (any zod failure is rejected as a whole batch and the error names the failing index and field, e.g. actions[2].data.teamId: Invalid UUID).
{
"actions": [
{
"type": "intake.create",
"data": {
"title": "Refund request from #support",
"teamId": "8c2c9d8e-1234-4abc-9def-1234567890ab",
"priority": "high",
"externalRef": { "externalKind": "zendesk:ticket", "externalId": "4821" }
}
},
{
"type": "metric.record_value",
"data": { "metricId": "1f6b8a3c-0d2e-4f7a-9b1c-2d3e4f5a6b7c", "value": 1240.5 }
},
{
"type": "task.transition",
"data": { "taskRef": "PLT-42", "toStatus": "done" }
}
]
}For intake.create, the item's source is always recorded as webhook server-side, regardless of input. The optional externalRef object links the created entity back to the external system.
| Action | Required / optional fields |
|---|---|
| intake.create | title, teamId / description, priority, externalRef |
| insight.create | customerId, verbatim, source / interpretation, externalRef |
| metric.record_value | metricId, value / recordedAt, note |
| task.create | title / description, teamId, opportunityId, workflowId, priority, externalRef |
| opportunity.create | title / description, teamId, workflowId, externalRef |
| task.transition | taskRef, toStatus |
| external_ref.attach | entityType, entityId, externalKind, externalId / url, metadata |
Sign every request with your integration's webhook secret and send the result in the X-Telos-Signature header. The scheme is the same one Telos uses for outbound deliveries: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<rawBody>">. Telos verifies in constant time and rejects timestamps more than 300 seconds from now, which blocks replay of captured requests.
import { createHmac } from "node:crypto";
export function signTelosRequest(rawBody: string, secret: string): string {
const t = Math.floor(Date.now() / 1000);
const v1 = createHmac("sha256", secret)
.update(t + "." + rawBody)
.digest("hex");
return "t=" + t + ",v1=" + v1;
}
// POST the raw body with headers:
// Content-Type: application/json
// X-Telos-Signature: <signTelosRequest(rawBody, secret)>
// X-Telos-Idempotency-Key: <stable id for this logical delivery>Send an X-Telos-Idempotency-Key header (up to 200 characters) that is stable per logical delivery. Redeliveries with the same key are acknowledged and dropped, so retrying senders never duplicate side effects. Without the header, Telos falls back to a hash of the raw body.
Every delivery is persisted to the integration's activity log at Configurations → Integrations before processing: the payload, the derived action list, per-action results, and any validation error. Failed deliveries can be replayed; actions that already succeeded are skipped.