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#

  1. Something happens (a lead is created, a transcript finishes, an appointment is booked).
  2. Lojiq finds your enabled subscriptions whose event_types include that event (or *), and queues one delivery per subscription.
  3. A dispatcher picks up due deliveries every 15 seconds and POSTs the JSON body to your target_url with a 10-second timeout.
  4. A 2xx marks it delivered. Anything else — 4xx, 5xx, a timeout, a connection error, or a redirect (never followed) — schedules a retry.

The request you receive#

http
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
  }
}
HeaderMeaning
X-Lojiq-EventThe event type, same as type in the body.
X-Lojiq-Event-IdSame as id. Identical on every retry — your idempotency key.
X-Lojiq-Delivery-Attempt1 on the first try, up to 8.
X-Lojiq-Signature-256t=<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:

  1. 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.
  2. Reject if |now − t| > 300 seconds (replay protection).
  3. Compare with a constant-time equality function.
javascript
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
});
python
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
text
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#

Attempt12345678
Delay before itimmediate30 s1 m5 m30 m2 h6 h12 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.updated may arrive before the lead.created it follows. Use occurred_at if order matters, or re-read the resource.
  • Payloads are compact by design (ids, not full records). On receipt, GET the 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_url at 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#

bash
# 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).

EventWhendataToday
lead.createdA 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.updatedAn 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_changedPATCH /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.startedA 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.completedA 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_readyAn 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.transferredA warm transfer to a human completed successfully.{ transfer_id, mode: "warm", outcome: "success" }.Fires
call.endedReserved: a call ended for any reason.Listed in the catalog but nothing emits it yet.Reserved
appointment.bookedAn appointment was booked by an AI agent or a person.{ appointment_id, lead_id, appointment_datetime, source }.Fires
appointment.rescheduledAn appointment's time changed.{ appointment_id, previous_datetime, appointment_datetime, changes: [field names] }.Fires
appointment.cancelledAn 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_changedPATCH /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_balanceYour balance crossed the low-balance threshold (checked by the billing monitor).{ current_balance, threshold, estimated_calls_remaining }.Fires
ai_engineer.onboarding_completedReserved.Listed in the catalog but nothing emits it yet.Reserved
Which event tells me a campaign call's outcome?

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.