Webhooks
A Webhook is an HTTPS endpoint that receives a tenant's events as they
happen. Where the management API is something you ask, a webhook is
something that tells you.
Endpoints are per tenant. How hard the PBX tries to deliver is global: that is a property of the process, not of the tenant being notified.
Configuring an endpoint
configVersion: lyno/v1
kind: Webhook
metadata:
name: crm
tenant: acme
spec:
url: https://crm.example.com/hooks/lyno
secretFile: /etc/lyno/secrets/crm
events: [call.answered, call.ended]
timeout: 10s
Every key is in the Webhook reference.
Omitting events subscribes to all of them.
The delivery defaults live on System:
configVersion: lyno/v1
kind: System
spec:
webhooks:
timeout: 10s
maxAttemptAge: 72h
concurrency: 8
concurrency caps in-flight deliveries across all tenants, and anything
above 64 is refused at startup: a flood of events must degrade webhooks, not
the process carrying calls.
dataDirThe delivery queue is durable, so it lives in the
data directory. Configure endpoints
without a dataDir and the PBX logs
webhooks are configured but there is no data_dir to queue them under, they will not be sent
at error level and carries on serving calls. Nothing is delivered and nothing is queued for later.
The events
A closed set. A misspelt name is a startup error rather than a subscription that quietly never fires.
| Event | Fires when | data |
|---|---|---|
call.ringing | A call starts alerting. | The call record so far. |
call.answered | A call is answered and bridged. | The call record so far. |
call.ended | A call finishes, however it ended. | The finished call log record. |
mobile.ringing | A follow-me or external leg starts alerting. | The ring: who is being reached, for which call. |
mobile.ended | That leg finishes. | call_id and outcome. |
voicemail.received | A message is stored. | The sidecar plus duration_seconds, without the audio path. |
registration.up | An endpoint registers. | extension, contact, expires. |
registration.down | Its registration lapses or is dropped. | The same, without expires. |
call.ended is enqueued after the record is committed to disk, never
before: a webhook must not announce a record the disk refused to keep.
The voicemail payload drops the audio path deliberately — a path inside the data root means nothing to a receiver — and adds the duration in seconds, a unit that needs no explaining. Fetch the audio from the management API.
The ringing events expire
call.ringing and mobile.ringing carry a 60-second expiry. Both exist to
make something ring now; delivered after the caller gave up, they are worse
than nothing, so the job is dropped rather than retried into next week's
on-call incident.
Every other event is retried until maxAttemptAge.
The envelope
One POST per event, Content-Type: application/json. The field order is
stable, because receivers diff and archive these.
{
"id": "...",
"type": "call.answered",
"created": "2026-07-26T14:02:11Z",
"tenant": "acme",
"data": { }
}
| Header | Value |
|---|---|
Lyno-Webhook-Id | The delivery id, the same as id in the body. |
Lyno-Webhook-Timestamp | Unix seconds, the moment it was signed. |
Lyno-Webhook-Signature | v1=<hex> — see below. |
User-Agent | lyno-pbx/<version> |
Any 2xx means the receiver owns the event. Anything else is a failure and is
retried.
Verifying the signature
The signature is HMAC-SHA256 over the timestamp, a literal ., and the raw
body, keyed with the endpoint's secret, hex-encoded and prefixed v1=.
v1=hex(HMAC_SHA256(secret, "<timestamp>.<raw body>"))
Sign the bytes you received, not a re-serialised object — re-encoding JSON changes whitespace and key order and the MAC with it.
import hmac, hashlib, time
def verify(secret: str, headers, body: bytes, tolerance: int = 300) -> bool:
ts = headers["Lyno-Webhook-Timestamp"]
if abs(time.time() - int(ts)) > tolerance: # replay window
return False
want = "v1=" + hmac.new(
secret.encode(), f"{ts}.".encode() + body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(want, headers["Lyno-Webhook-Signature"])
The timestamp is inside the MAC, so a captured delivery cannot be replayed later with a fresh-looking header — a receiver that checks the timestamp window gets replay protection for free. Compare in constant time.
The v1= prefix names the scheme, so a future one can ship without every
receiver breaking on day one.
It proves who sent the payload and that it was not altered. It does not hide
it. With insecureHttp: true the payload and the MAC ride in clear, which is
why plaintext is opt-in and meant for a receiver on loopback or a private
segment.
Where the secret comes from
Most secure source first:
LYNO_WEBHOOK_SECRET_<TENANT>_<NAME>— uppercased, with-and.mapped to_;secretFile, read at startup and trimmed;- the inline
secret.
The inline form is the least good option, because configs/ tends to live in
version control. An endpoint may sign nothing at all, so an empty secret is
legal — the receiver decides what that means.
That variable-name mapping is lossy: a-b and a_b map to the same variable.
Two endpoints of one tenant whose names collide that way are refused at
startup rather than silently sharing a secret.
Delivery and retries
The queue is durable: jobs are written to the data directory, so a delivery survives a restart. It is the same queue the S3 upload path uses.
A failed delivery is retried until maxAttemptAge has passed, after which it
is given up on and logged. The exception is the two ringing events above, which
expire after a minute.
A malformed URL, or a plain http URL on an endpoint without insecureHttp,
is a permanent failure: retrying cannot fix configuration, so it is not
retried.
Response bodies are read up to 64 KB and discarded on success. What a receiver says when it succeeds is its own business.
A restart re-announces registration.up once per registered endpoint: the
deduplication is in memory, and a receiver that cares should treat these as
idempotent on extension. Voicemail is swept immediately on startup, which is
also the crash recovery — a message recorded but never announced still has no
stamp on it.
Validation
- a
urlthat is nothttps, unlessinsecureHttpis set; - an event name outside the closed set;
- a
secretFilethat cannot be read; - two endpoints of one tenant whose secret environment variables would collide;
- negative
webhooksdurations, a negativeconcurrency, or aconcurrencyabove 64.