Skip to content

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. A participant.left can arrive before its participant.joined.
  • Answer fast. Reply 2xx within 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, then PATCH it 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

  1. POST /v1/webhooks/{id}/rotate-secret and store the new secret.
  2. For the next 24 hours every request carries two signatures, one per secret, so either verifies.
  3. Switch your endpoint to the new secret within that window.