Skip to main content

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.

Webhooks need a dataDir

The 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.

EventFires whendata
call.ringingA call starts alerting.The call record so far.
call.answeredA call is answered and bridged.The call record so far.
call.endedA call finishes, however it ended.The finished call log record.
mobile.ringingA follow-me or external leg starts alerting.The ring: who is being reached, for which call.
mobile.endedThat leg finishes.call_id and outcome.
voicemail.receivedA message is stored.The sidecar plus duration_seconds, without the audio path.
registration.upAn endpoint registers.extension, contact, expires.
registration.downIts 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": { }
}
HeaderValue
Lyno-Webhook-IdThe delivery id, the same as id in the body.
Lyno-Webhook-TimestampUnix seconds, the moment it was signed.
Lyno-Webhook-Signaturev1=<hex> — see below.
User-Agentlyno-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.

The signature is not confidentiality

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:

  1. LYNO_WEBHOOK_SECRET_<TENANT>_<NAME> — uppercased, with - and . mapped to _;
  2. secretFile, read at startup and trimmed;
  3. 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 url that is not https, unless insecureHttp is set;
  • an event name outside the closed set;
  • a secretFile that cannot be read;
  • two endpoints of one tenant whose secret environment variables would collide;
  • negative webhooks durations, a negative concurrency, or a concurrency above 64.