Webhooks
Get events pushed to your server instead of polling.
Events
| Type | Fires when |
|---|---|
call.completed |
A call ends and its transcript, summary, and insights are ready. |
call.transferred |
A call was transferred to a human. |
appointment.booked |
An agent booked an appointment. |
appointment.cancelled |
An appointment was cancelled. |
lead.captured |
The agent captured a lead during a call. |
usage.threshold_reached |
The account hit 80% or 100% of its included minutes. |
Payload envelope — data is the same schema the REST API returns for the
resource:
{
"id": "evt_3c9d8e7f6a5b4c3d2e1f0a9b",
"type": "call.completed",
"created": 1752841265,
"data": {
"id": "call_9f8e7d6c5b4a3f2e1d0c9b8a",
"agent_id": "agt_1a2b3c4d5e6f7a8b9c0d1e2f",
"status": "completed",
"summary": "Caller asked about availability; agent booked a viewing.",
"outcome": "booked"
}
}
Subscribe
Via the dashboard (Developers → Webhooks) or the API
(read_write scope):
curl -X POST https://voice-public-api.essere.ai/v1/webhook-endpoints \
-H "Authorization: Bearer $ESSERE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/essere/webhook",
"events": ["call.completed", "appointment.booked"],
"description": "CRM sync"
}'
The response includes the signing secret (whsec_...) — shown once.
An empty events array subscribes to all event types.
Send a test event any time:
curl -X POST https://voice-public-api.essere.ai/v1/webhook-endpoints/we_5a4b3c2d1e0f9a8b7c6d5e4f/ping \
-H "Authorization: Bearer $ESSERE_API_KEY"
Verify signatures
Every delivery is signed:
X-Essere-Signature: t=1752841265,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
v1 is the hex HMAC-SHA256 of "{t}.{raw request body}" with your
whsec_ secret. Verify before trusting the payload, and reject
timestamps older than 5 minutes (replay protection). Compute over the RAW
bytes — do not re-serialize the JSON.
During a secret rotation the header carries two v1
entries (t=..,v1=<new>,v1=<old>). Your verifier must accept the delivery if
any v1 matches — the snippets below already do.
Python (Flask example)
import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
WEBHOOK_SECRET = os.environ["ESSERE_WEBHOOK_SECRET"] # from endpoint creation
def verify_essere_signature(payload: bytes, header: str, secret: str,
tolerance: int = 300) -> bool:
t = None
v1s: list[str] = []
for part in header.split(","):
key, _, value = part.partition("=")
if key == "t":
try:
t = int(value)
except ValueError:
return False
elif key == "v1":
v1s.append(value) # rotation overlap: there may be TWO v1 entries
if t is None or not v1s:
return False
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + payload,
hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, v1) for v1 in v1s)
@app.post("/essere/webhook")
def essere_webhook():
sig = request.headers.get("X-Essere-Signature", "")
if not verify_essere_signature(request.get_data(), sig, WEBHOOK_SECRET):
abort(400)
event = request.get_json()
if event["type"] == "call.completed":
print("call finished:", event["data"]["id"], event["data"]["outcome"])
return "", 200
Node (Express example)
const crypto = require('node:crypto')
const express = require('express')
const app = express()
const WEBHOOK_SECRET = process.env.ESSERE_WEBHOOK_SECRET // from endpoint creation
function verifyEssereSignature(rawBody, header, secret, toleranceSeconds = 300) {
let t = null
const v1s = [] // rotation overlap: the header may carry TWO v1 entries
for (const part of header.split(',')) {
const i = part.indexOf('=')
const key = part.slice(0, i)
const value = part.slice(i + 1)
if (key === 't') t = Number(value)
else if (key === 'v1') v1s.push(value)
}
if (!t || v1s.length === 0) return false
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false
const expected = crypto.createHmac('sha256', secret)
.update(`${t}.`).update(rawBody).digest('hex')
const a = Buffer.from(expected)
return v1s.some((v1) => {
const b = Buffer.from(v1)
return a.length === b.length && crypto.timingSafeEqual(a, b)
})
}
// express.raw keeps the exact bytes — required for signature verification
app.post('/essere/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.get('X-Essere-Signature') || ''
if (!verifyEssereSignature(req.body, sig, WEBHOOK_SECRET)) {
return res.status(400).end()
}
const event = JSON.parse(req.body)
if (event.type === 'call.completed') {
console.log('call finished:', event.data.id, event.data.outcome)
}
res.status(200).end()
})
app.listen(3000)
Secret rotation
Rotate an endpoint's signing secret any time (dashboard Developers →
Webhooks → Rotate secret, or POST /v1/webhook-endpoints/{id}/rotate-secret). The response shows the NEW
whsec_ secret once. For the next 24 hours every delivery is signed
with both secrets — the header carries two v1 entries — so you can deploy
the new secret without dropping verifications. After the overlap, only the
new secret signs.
Delivery behavior
- At-least-once: rarely, you may see the same
idtwice — deduplicate onevent.id; make your handler idempotent. - Ordering is not guaranteed: retries and parallel delivery mean events
can arrive out of order — order by the envelope's
createdtimestamp (or refetch the resource), never by arrival time. - Success = any 2xx response within 10 seconds. Respond fast; do slow work async. Redirects are not followed — a 3xx counts as a failure.
- Retries: failed deliveries retry with backoff (± jitter) — about 8
attempts spread over 24 hours. A
Retry-Afterheader on your response is honored (up to 1 hour). Endpoints answering 404/410/401/403 stop being retried after 3 attempts. - Auto-disable: after sustained failure across consecutive events, the endpoint is disabled and you're notified by email. Re-enable it in the dashboard once fixed; missed events can be redelivered from the delivery log.
- HTTPS only: endpoint URLs must be public HTTPS URLs.
- Inspect deliveries:
GET /v1/webhook-endpoints/{id}/deliveriesor the dashboard delivery log (delivery metadata only — payloads are not echoed back through the log).