Webhooks guide
Outcomes out. Subscribe an HTTPS endpoint once and Lojiq POSTs every matching event to it, signed, with retries and a stable event id. No polling.
How delivery works#
- Something happens (a lead is created, a transcript finishes, an appointment is booked).
- Lojiq finds your enabled subscriptions whose
event_typesinclude that event (or*), and queues one delivery per subscription. - A dispatcher picks up due deliveries every 15 seconds and POSTs the JSON body to your
target_urlwith a 10-second timeout. - A
2xxmarks it delivered. Anything else —4xx,5xx, a timeout, a connection error, or a redirect (never followed) — schedules a retry.
The request you receive#
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: lojiq-webhooks/1.0
X-Lojiq-Event: call.transcript_ready
X-Lojiq-Event-Id: c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f
X-Lojiq-Delivery-Attempt: 1
X-Lojiq-Signature-256: t=1759424591,v1=3f1c9e5a…
{
"id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f",
"type": "call.transcript_ready",
"organization_id": "7f6e5d4c-3b2a-4190-8877-665544332211",
"occurred_at": "2026-10-02T17:06:15Z",
"data": {
"call_id": "5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b",
"call_sid": "a1b2c3d4e5f6",
"campaign_id": "0d2f6b1e-4a7c-4e8d-9b1a-2c3d4e5f6a7b",
"lead_id": "4b1e0d1a-3c4e-4d0a-9f2b-7e1a2b3c4d5e",
"duration_seconds": 184,
"summary": "Caller agreed to a consultation on Thursday at 2pm.",
"has_transcript": true
}
}
| Header | Meaning |
|---|---|
X-Lojiq-Event | The event type, same as type in the body. |
X-Lojiq-Event-Id | Same as id. Identical on every retry — your idempotency key. |
X-Lojiq-Delivery-Attempt | 1 on the first try, up to 8. |
X-Lojiq-Signature-256 | t=<unix seconds>,v1=<hex HMAC-SHA256>. Verify it; see below. |
The body is always { id, type, organization_id, occurred_at, data }. Only data varies by event. Fields may be added to data without notice; parse leniently.
Verifying the signature#
The signature is HMAC-SHA256(secret, "<t>.<raw request body>"), hex-encoded, where secret is the whsec_… value returned once when you created the subscription. Three rules:
- Hash the raw bytes you received. Do not parse and re-serialise JSON first — key order and whitespace would change and the HMAC would fail.
- Reject if
|now − t| > 300seconds (replay protection). - Compare with a constant-time equality function.
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.LOJIQ_WEBHOOK_SECRET; // whsec_… from POST /webhooks
export function verifyLojiqSignature(rawBody, header, secret, toleranceSec = 300) {
if (!header) return false;
const parts = Object.fromEntries(header.split(',').map(p => p.trim().split('=')));
const t = parseInt(parts.t, 10);
if (!t || !parts.v1) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`, 'utf8').digest('hex');
if (expected.length !== parts.v1.length) return false;
return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(parts.v1, 'hex'));
}
const app = express();
// express.raw keeps the exact bytes; express.json() would re-serialise them.
app.post('/lojiq', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => {
const raw = req.body.toString('utf8');
if (!verifyLojiqSignature(raw, req.get('X-Lojiq-Signature-256'), SECRET)) return res.status(401).end();
const event = JSON.parse(raw);
if (await alreadyProcessed(event.id)) return res.status(204).end(); // retries reuse the id
await enqueue(event); // do the real work off the request
res.status(204).end(); // answer fast; 10 s budget
});
import hashlib, hmac, os, time
from flask import Flask, request
SECRET = os.environ["LOJIQ_WEBHOOK_SECRET"] # whsec_… from POST /webhooks
app = Flask(__name__)
def verify_lojiq_signature(raw_body: bytes, header: str | None, secret: str, tolerance_sec: int = 300) -> bool:
if not header:
return False
parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
try:
t = int(parts["t"]); v1 = parts["v1"]
except (KeyError, ValueError):
return False
if abs(int(time.time()) - t) > tolerance_sec:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
@app.post("/lojiq")
def lojiq_webhook():
raw = request.get_data() # raw bytes, before any JSON parsing
if not verify_lojiq_signature(raw, request.headers.get("X-Lojiq-Signature-256"), SECRET):
return "", 401
event = request.get_json(force=True)
if already_processed(event["id"]): # retries reuse the id
return "", 204
enqueue(event) # do the real work off the request
return "", 204
secret : whsec_0000000000000000000000000000000000000000000000000000000000000000
t : 1759424591
body : {"id":"evt_test","type":"lead.created","organization_id":"org_test","occurred_at":"2026-10-02T17:03:11Z","data":{"lead_id":"lead_test","deduped":false}}
string : 1759424591.{"id":"evt_test",…}
v1 : run `echo -n "$string" | openssl dgst -sha256 -hmac "$secret"` — your implementation must produce the same hex.
Retries and failure handling#
| Attempt | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| Delay before it | immediate | 30 s | 1 m | 5 m | 30 m | 2 h | 6 h | 12 h |
After the eighth failed attempt (about 20 hours), the delivery is marked dead_letter and not retried. Each failed attempt also increments the subscription's consecutive_failures; at 25 the subscription is disabled (enabled: false, disabled_reason: "auto_disabled_after_25_consecutive_failures") and stops receiving anything, including queued retries. A single success resets the counter. Re-enable with PATCH /webhooks/{id} {"enabled": true} once your endpoint is healthy; events that occurred while disabled are not replayed.
Check health with GET /webhooks: last_success_at, last_failure_at, consecutive_failures, disabled_reason.
Idempotency and ordering#
- The same event can reach you more than once (a retry after a timeout you actually handled). Store processed
ids and ignore repeats. - Deliveries are not ordered.
lead.updatedmay arrive before thelead.createdit follows. Useoccurred_atif order matters, or re-read the resource. - Payloads are compact by design (ids, not full records). On receipt,
GETthe resource if you need the current state.
Endpoint rules#
https://only, publicly resolvable, no username/password in the URL.- Private, loopback, link-local, carrier-NAT and cloud-metadata addresses are refused at creation (
400 private_hostname,private_resolved_ipv4, …) and re-checked on every delivery; a subscription whose DNS starts resolving to a private address dead-letters its deliveries. - Redirects are not followed. Point
target_urlat the final URL. - Answer within 10 seconds. Queue the work and return
204. - Allow-listing by IP is not supported yet; verify the signature instead.
Managing subscriptions#
# create
curl -s -X POST "https://api.lojiq.ai/v1/webhooks" -H "Authorization: Bearer $LOJIQ_KEY" -H "Content-Type: application/json" \
-d '{"name":"crm-sync","target_url":"https://example.com/lojiq","event_types":["lead.created","call.transcript_ready","appointment.booked"]}'
# list (secrets are never returned)
curl -s "https://api.lojiq.ai/v1/webhooks" -H "Authorization: Bearer $LOJIQ_KEY"
# change events / re-enable
curl -s -X PATCH "https://api.lojiq.ai/v1/webhooks/6a5b…" -H "Authorization: Bearer $LOJIQ_KEY" -H "Content-Type: application/json" \
-d '{"event_types":["*"],"enabled":true}'
# delete
curl -s -X DELETE "https://api.lojiq.ai/v1/webhooks/6a5b…" -H "Authorization: Bearer $LOJIQ_KEY"
The secret is returned only by the create call. To rotate it, create a second subscription with the new target or the same target, switch your verifier to accept both secrets, then delete the old subscription. Scope for all of this: webhooks:manage.
All events#
14 event types are in the catalog (GET /event-catalog). The Today column is honest about which ones currently fire — two are reserved and never emitted yet, and several fire only for actions taken through this API. The gaps are on the roadmap (see Changelog).
| Event | When | data | Today |
|---|---|---|---|
lead.created | A lead was inserted through POST /leads or a bulk CSV import. | { lead_id, deduped: false, source: "public_api" } from the API; { lead_id, source: "csv_import", job_id } from bulk import. | Fires |
lead.updated | An existing lead was updated by a deduplicated POST /leads, a bulk-import row, or a PATCH that did not change status. | { lead_id, deduped: true, source: "public_api" }, { lead_id, source: "csv_import", job_id }, or { lead_id, changes: [the field names you sent], source: "public_api" }. | Fires |
lead.disposition_changed | PATCH /leads/{id} changed status. | { lead_id, changes: [the field names you sent], source: "public_api" }. Dispositions written by the dialer or by reps in the app do not emit this yet. | Fires |
call.started | A call started through this API (phone or browser). | { call_id, direction: "web" | "phone", agent_id, lead_id, from_number, to_number }. Campaign and receptionist calls do not emit it yet. | Fires |
call.completed | A browser call started through this API ended normally. | { call_id, direction: "web", status: "completed", duration_seconds, agent_id, lead_id }. Phone and campaign calls do not emit it yet. | Fires |
call.transcript_ready | An AI call's transcript and/or summary finished processing (any AI call, including campaign calls). | { call_id, call_sid, campaign_id, lead_id, duration_seconds, summary, has_transcript }. The most useful "outcome" event today. | Fires |
call.transferred | A warm transfer to a human completed successfully. | { transfer_id, mode: "warm", outcome: "success" }. | Fires |
call.ended | Reserved: a call ended for any reason. | Listed in the catalog but nothing emits it yet. | Reserved |
appointment.booked | An appointment was booked by an AI agent or a person. | { appointment_id, lead_id, appointment_datetime, source }. | Fires |
appointment.rescheduled | An appointment's time changed. | { appointment_id, previous_datetime, appointment_datetime, changes: [field names] }. | Fires |
appointment.cancelled | An appointment was cancelled in the app or via POST /appointments/{id}/cancel. | { appointment_id, reason } (plus source: "public_api" when cancelled through the API). | Fires |
deal.status_changed | PATCH /deals/{id} changed status or stage. | { deal_id, status, stage }. Deals are not yet available on production, so this cannot fire yet. | Fires |
billing.low_balance | Your balance crossed the low-balance threshold (checked by the billing monitor). | { current_balance, threshold, estimated_calls_remaining }. | Fires |
ai_engineer.onboarding_completed | Reserved. | Listed in the catalog but nothing emits it yet. | Reserved |
call.transcript_ready. It fires for every AI call that produced a transcript or summary — campaign calls included — and carries call_id, lead_id, campaign_id, duration_seconds and the summary. Follow up with GET /calls/{id} for call_outcome and the recording. call.completed and lead.disposition_changed cover only API-initiated actions today.