Webhooks¶
Tele can tell your backend when something happens: a call starts or ends, someone joins or leaves, a recording is ready, a chat message is saved. You register an HTTPS endpoint, Tele sends each event as a signed POST, and retries until your endpoint answers with a 2xx.
Register an endpoint¶
Webhooks are managed with a service token. A key can have up to 10 endpoints.
curl -X POST https://tele.cloudguys.io/v1/webhooks -H "Authorization: Bearer $SERVICE_TOKEN" \
-d '{"url": "https://api.example.com/tele/webhooks", "events": ["call.ended", "recording.ready"], "description": "billing"}'
{
"id": "01a15c9e-…", "url": "https://api.example.com/tele/webhooks", "events": ["call.ended", "recording.ready"],
"enabled": true, "secret": "whsec_kq3V…", "created_at": "2026-10-19T10:00:00Z"
}
Store secret now: it is only returned here and when you rotate it. Use ["*"] to receive every event type, including ones added later.
The URL must use https and resolve to a public address. Redirects are not followed.
Events¶
| Type | When |
|---|---|
call.created |
A call was created |
call.updated |
A call was changed, or ended by the host or the scheduler |
call.cancelled |
A call was cancelled |
call.started |
The first person joined a room session |
participant.joined |
Someone joined |
participant.left |
Someone left, or was closed when the room ended |
call.ended |
A room session closed. Carries duration and usage |
recording.started |
A recording began |
recording.ready |
A recording finished and can be downloaded |
recording.failed |
A recording did not complete |
chat.message.created |
A chat message was saved. Ids only, never the text |
A call can have more than one room session if people leave and come back after the room closes, so call.started and call.ended are per session (session.id).
Webhooks never include IP addresses or chat text. Read those from the sessions and chat history endpoints if you need them.
Payload¶
Every event uses the same envelope:
{
"id": "01a15ca2-5f0e-7c11-9a43-0f2e8c1b7d55",
"type": "call.ended",
"api_version": "2026-10-19",
"occurred_at": "2026-10-19T10:10:35Z",
"data": {
"call": { "id": "01a15c9e-…", "title": "Weekly sync", "host_user_id": "user-123" },
"session": {
"id": "01a15ca0-…", "started_at": "2026-10-19T10:00:30Z", "ended_at": "2026-10-19T10:10:35Z",
"end_reason": "ROOM_END_IDLE_TIMEOUT", "billable_seconds": 605,
"peak_participants": 2, "unique_participants": 2
},
"usage": { "metric": "room_minutes", "billable_minutes": "11.0000", "amount": "11.0000", "currency": "BDT" }
}
}
participant.joined and participant.left carry data.participant with user_id, name, platform, joined_at, left_at and disconnect_reason. Recording events carry data.recording with id, status, duration_ms and size_bytes; download it with GET /v1/recordings/{id}/download.
Verify the signature¶
Tele signs with Standard Webhooks, so any of its libraries works. Each request has three headers:
| Header | |
|---|---|
webhook-id |
The event id. Use it to drop duplicates |
webhook-timestamp |
Unix seconds when it was sent |
webhook-signature |
v1,<base64 HMAC-SHA256>; more than one during a secret rotation |
Verify against the raw body, before parsing JSON, and reject timestamps more than 5 minutes off.
func teleWebhook(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
if err := teletoken.VerifyWebhook(os.Getenv("TELE_WEBHOOK_SECRET"), r.Header, body); err != nil {
http.Error(w, "bad signature", http.StatusBadRequest)
return
}
// json.Unmarshal(body, &event) and handle it
w.WriteHeader(http.StatusNoContent)
}
import { verifyWebhook } from "@tele/node";
app.post("/tele/webhooks", express.raw({ type: "application/json" }), (req, res) => {
try {
const event = verifyWebhook(process.env.TELE_WEBHOOK_SECRET!, req.headers, req.body);
// handle event.type
res.sendStatus(204);
} catch {
res.sendStatus(400);
}
});
import os
from fastapi import Depends, FastAPI
from tele_fastapi import TeleEvent
from tele_fastapi.fastapi import TeleWebhook
app = FastAPI()
@app.post("/tele/webhooks", status_code=204, response_model=None)
async def tele_webhook(event: TeleEvent = Depends(TeleWebhook(os.environ["TELE_WEBHOOK_SECRET"]))):
... # handle event.type; drop duplicates by event.id
import base64, hashlib, hmac, time
def verify(secret: str, headers, body: bytes) -> None:
msg_id, ts, sigs = headers["webhook-id"], headers["webhook-timestamp"], headers["webhook-signature"]
if abs(time.time() - int(ts)) > 300:
raise ValueError("stale")
key = base64.b64decode(secret.removeprefix("whsec_"))
want = "v1," + base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()).decode()
if not any(hmac.compare_digest(s, want) for s in sigs.split()):
raise ValueError("bad signature")
Delivery¶
- At least once. The same event can arrive twice; keep the
webhook-ids you have handled. - Unordered. Use
occurred_at, not arrival order. Aparticipant.leftcan arrive before itsparticipant.joined. - Answer fast. Reply
2xxwithin 10 seconds and do slow work afterwards. Anything else counts as a failure. - Retries. After a failure Tele waits 5 s, 30 s, 2 min, 10 min, 30 min, 1 h, 3 h, 6 h and 12 h, then marks the delivery
failed(about a day in all). - Auto-disable. An endpoint that has failed for 3 days with no success is disabled and its queue marked
failed. Fix it, thenPATCHit with{"enabled": true}.
Events that happen while an endpoint is disabled are not queued for it.
Manage endpoints¶
| Method | Path | Description |
|---|---|---|
GET |
/v1/webhooks |
List endpoints |
GET |
/v1/webhooks/{id} |
One endpoint, including failing_since |
PATCH |
/v1/webhooks/{id} |
{url?, events?, description?, enabled?} |
DELETE |
/v1/webhooks/{id} |
Remove it and its delivery log |
POST |
/v1/webhooks/{id}/rotate-secret |
New secret; the old one keeps signing for 24 hours |
POST |
/v1/webhooks/{id}/test |
Send a webhook.test event |
GET |
/v1/webhooks/{id}/deliveries |
Recent deliveries. ?status=pending\|delivered\|failed&limit= |
POST |
/v1/webhook-deliveries/{id}/retry |
Send a delivery again now |
Delivery logs are kept for 30 days.
Rotating the secret¶
POST /v1/webhooks/{id}/rotate-secretand store the new secret.- For the next 24 hours every request carries two signatures, one per secret, so either verifies.
- Switch your endpoint to the new secret within that window.