Webhook System Architecture & Flow
What It Does
Customers register HTTPS endpoints to receive real-time POST notifications when call lifecycle events happen. The system uses BullMQ for reliable async delivery with retries, HMAC-SHA256 signing for payload verification, and full delivery logging.
Webhooks support two payload modes:
- Standard Fixed versioned payload with HMAC signing (original behavior)
- Custom Full HTTP control with configurable method, headers, body templates, and auth
Webhooks are the correct tool for delivering call data outward. They fire on call lifecycle events, so delivery is deterministic. The other two tool types (client tools and MCP integrations) run during a call and only if the LLM decides to invoke them, which makes them unsuitable for guaranteed delivery.
- Configure at
/tools→ Add webhook tool - Deliveries are visible at
/logs→ Webhook Logs, one row per attempt
See TOOLS-AND-LOGS.md for how the three tool types differ and which Logs tab shows each one.
Versioning & Stability Contract
Every webhook payload includes a "version": "1.0" field. This is a public API contract:
- The
versionfield will be bumped (e.g."1.1","2.0") before any breaking change - New fields may be added to
datawithout a version bump (additive changes are non-breaking) - Existing fields will never be renamed or removed within the same major version
- Consumers should ignore unknown fields to stay forward-compatible
- The
typefield values (call.ended, etc.) are stable identifiers they won't change
This means once an organization integrates with version "1.0", their integration won't break unless they opt into a new major version.
Event Types
| Event | Fires When |
|---|---|
call.ended | Call disconnects (participant_left webhook processed) |
call.transcription_ready | Transcription stored on Contact (POST /transcription-sync) |
call.analysis_ready | Analysis stored on Contact (direct MongoDB write by Analysis Backend, notified via POST /analysis-ready) includes custom analysis schema results |
call.recording_ready | Recording URL set on Contact (egress_ended processed, S3 verified) |
call.completed | All 4 above are present on the same Contact |
Architecture
Campaign Run Full Webhook Timeline
When a campaign runs and a call completes, here's the exact sequence of webhook events:
Key point: call.completed only fires once all 4 data points (call status, transcription, analysis, recording) are present. Each hook checks isContactComplete() after its own event whichever hook runs last triggers call.completed.
Delivery Flow
- Lifecycle hook calls
webhookService.enqueueEvent()(fire-and-forget, never blocks) - Service checks feature access → skips silently if
webhooksfeature not enabled - Skips if
contact.isTestCall === true - Queries active, non-deleted endpoints matching customerUID + eventType
- Agent filtering filters out endpoints where
agentIdsis non-empty and does not includecontactData.AgentID - Campaign webhook lookup queries
CampaignWebhookassociations for matching campaignUID + webhookId - Condition evaluation evaluates conditions (campaign-level overrides > endpoint-level > unconditional)
- Payload mode routing routes to standard (HMAC) or custom (interpolated HTTP) delivery path
- Checks deduplication skips if eventId already delivered successfully
- Adds one BullMQ job per matching endpoint
Worker picks up job (Standard mode):
- Reads encrypted secret from Redis (
webhook:secret:{webhookId}) - Decrypts with AES-256-GCM
- Computes HMAC-SHA256 signature over
{timestamp}.{jsonBody} - HTTP POST with headers:
X-Oktivo-Signature,X-Oktivo-Event,X-Oktivo-Delivery-Id,X-Oktivo-Timestamp - 2xx → success, reset failure counter
- Non-2xx/timeout → failed, increment failure counter, retry with backoff
Worker picks up job (Custom mode):
- Resolves
{{variable}}placeholders in URL, headers, and body template from contact data - Applies configured auth (Bearer / API Key / Basic Auth)
- Sends HTTP request with configured method (GET, POST, PUT, PATCH, DELETE)
- Uses endpoint-specific timeout (
timeoutMs, default 10000ms) - 2xx → success
- Non-2xx/timeout → failed, retry if
retryOnFailureenabled
Body interpolation is content-type aware
Interpolation is chosen by bodyType, so templated values can never corrupt the request:
bodyType | Interpolation |
|---|---|
json | JSON-aware. "{{key}}" where the value is an object/array (analysis, transcription) is spliced in unquoted so it becomes a real nested object. Any other value is JSON-escaped, so quotes, backslashes and newlines in a summary or transcript can't break the payload. |
form | Values are percent-encoded; the a={{x}}&b={{y}} structure is preserved. |
raw | Verbatim substitution. |
none | No body sent. |
URLs are interpolated too placeholders in the query string are percent-encoded.
Empty body template: a custom-mode endpoint with bodyType: "json" and no bodyTemplate falls back to the standard versioned payload, so it still receives the full call data instead of an empty request.
Deduplication is per endpoint
Delivery dedupe is keyed on (eventId, webhookId). One endpoint already having received an event never suppresses delivery to the customer's other endpoints.
Retry Schedule
| Attempt | Delay |
|---|---|
| 1 | 10 seconds |
| 2 | 30 seconds |
| 3 | 1 minute |
| 4 | 5 minutes |
| 5 | 15 minutes |
| 6 | 30 minutes |
| 7 | 1 hour |
| 8 | 2 hours |
After 8 failed retries → delivery marked as failed permanently.
After 50 consecutive failures across any events → endpoint auto-disabled with disabledReason: "consecutive_failures".
Custom mode retries: When retryOnFailure is enabled, custom-mode endpoints use the same schedule but respect maxRetries (0–10, default 3). If retryOnFailure is false, no retries are attempted.
Multi-Agent Filtering
Webhook endpoints can be scoped to one or more agents. When agentIds is set on an endpoint, it only receives events for calls made by those agents. Endpoints with empty agentIds ([]) receive all events (backward compatible).
How It Works
- When creating/updating an endpoint, pass an optional
agentIdsarray (max 50 agent IDs) - Deduplication: duplicate agent IDs are removed on save
- In
enqueueEvent(), after querying active endpoints, the service filters out endpoints whereagentIdsis non-empty and does not includecontactData.AgentID - Agent validation: each provided agentId is verified to exist and belong to the customer
Migration from agentId (single)
The agentId field has been replaced by agentIds (array). A migration script converts existing single-agent entries:
agentId: "abc"→agentIds: ["abc"]agentId: null→agentIds: []
Script: scripts/migrate-agentId-to-agentIds.ts
Use Cases
- Multi-CRM routing: Different CRM systems connected to different agents each receives only its own call data
- Department isolation: Sales agent webhooks go to Salesforce, support agent webhooks go to Zendesk
- Multi-agent endpoints: A single analytics endpoint receives data from multiple agents
- API-triggered calls: CRM triggers a call via
/api/v1/crm/calls→ analysis results delivered back via agent-filtered webhook
API Examples
Create Endpoint with multiple agents:
{
"url": "https://crm.example.com/webhook",
"eventTypes": ["call.analysis_ready", "call.completed"],
"description": "Sales CRM handles two agents",
"agentIds": ["SZ24gF9tGk-o", "WtJXFwdffRPU"]
}All-agent endpoint (empty array = all):
{
"url": "https://analytics.example.com/ingest",
"eventTypes": ["call.completed"],
"description": "Analytics all agents",
"agentIds": []
}Custom Payload Mode
Webhooks now support two payload modes via the payloadMode field:
| Mode | Behavior |
|---|---|
standard | Fixed versioned payload, HMAC-SHA256 signed (original behavior) |
custom | Full HTTP control custom method, headers, body template, auth |
Custom mode gives the same power as API Actions but within the unified webhook system. This replaces the need for separate API Action configurations.
Custom Mode Fields
| Field | Type | Description |
|---|---|---|
payloadMode | "standard" | "custom" | Delivery mode (default: "standard") |
method | enum | HTTP method: GET, POST, PUT, PATCH, DELETE (custom mode only) |
headers | array | Custom headers [{key, value, enabled}] |
bodyType | enum | json, form, raw, none |
bodyTemplate | string | Body with {{variable}} placeholders |
auth | object | {type, token, headerName, username, password} |
conditions | array | Conditional execution rules (max 20) |
timeoutMs | number | Request timeout in ms (1000–30000, default 10000) |
retryOnFailure | boolean | Enable retries for custom mode |
maxRetries | number | Max retry attempts (0–10, default 3) |
Auth Configuration
{
"auth": {
"type": "bearer",
"token": "your-api-key-here"
}
}Supported auth types:
- Bearer:
Authorization: Bearer {token} - API Key: Custom header with key value
{headerName}: {token} - Basic Auth:
Authorization: Basic base64({username}:{password}) - None: No auth header added
Template Variables
Body templates use {{variable}} placeholders resolved from contact data at delivery time.
| Variable | Source |
|---|---|
{{phone}} | Contact.phone |
{{name}} | Contact.name |
{{contactUID}} | Contact.contactUID |
{{campaignUID}} | Contact.campaignUID |
{{agentId}} | Contact.AgentID |
{{orgUID}} | Contact.orgUID |
{{callStatus}} | Contact.callStatus |
{{disconnectReason}} | Contact.disconnectReason |
{{billingDuration}} | Contact.billingDuration |
{{callDuration}} | Contact.callDuration |
{{attempts}} | Contact.attempts |
{{recordingUrl}} | Contact.recordingUrl (presigned S3 URL) |
{{summary}} | Contact.analysis.summary |
{{sentiment}} | Contact.analysis.sentiment |
{{analysis}} | Contact.analysis (full JSON) |
{{transcription}} | Contact.transcription (full JSON) |
{{dynamicVariables}} | Contact.dynamicVariables (full JSON) |
{{extraCsvData}} | Contact.extraCsvData (full JSON) |
Use GET /webhooks/template-variables for the full grouped list.
Example Custom Mode Endpoint
{
"url": "https://api.mycrm.com/leads/update",
"eventTypes": ["call.analysis_ready"],
"payloadMode": "custom",
"method": "POST",
"headers": [
{ "key": "X-Source", "value": "oktivo", "enabled": true }
],
"bodyType": "json",
"bodyTemplate": "{\"phone\": \"{{phone}}\", \"name\": \"{{name}}\", \"interested\": \"{{analysis.is_interested}}\", \"summary\": \"{{summary}}\"}",
"auth": { "type": "bearer", "token": "sk_live_abc123" },
"agentIds": ["SZ24gF9tGk-o"],
"conditions": [
{ "field": "is_interested", "operator": "eq", "value": true }
],
"timeoutMs": 15000,
"retryOnFailure": true,
"maxRetries": 5
}Conditional Execution
Webhooks can have conditions that must ALL be met (AND logic) before the webhook fires. Conditions are evaluated against the contact's analysis data.
Condition Schema
{
"conditions": [
{ "field": "is_interested", "operator": "eq", "value": true },
{ "field": "sentiment", "operator": "eq", "value": "positive" },
{ "field": "lead_score", "operator": "gt", "value": 5 }
]
}Maximum 20 conditions per endpoint.
Supported Operators
| Operator | Description | Example |
|---|---|---|
eq | Equals (case-insensitive for strings) | {"field": "sentiment", "operator": "eq", "value": "positive"} |
neq | Not equals (also true if field is missing) | {"field": "callStatus", "operator": "neq", "value": "no_answer"} |
exists | Field exists and is not null/undefined | {"field": "budget_range", "operator": "exists"} |
not_exists | Field is null/undefined/missing | {"field": "budget_range", "operator": "not_exists"} |
contains | String contains substring (case-insensitive) | {"field": "summary", "operator": "contains", "value": "interested"} |
gt | Greater than (numeric) | {"field": "lead_score", "operator": "gt", "value": 7} |
lt | Less than (numeric) | {"field": "lead_score", "operator": "lt", "value": 3} |
Evaluation Rules
- Fields are resolved from
contact.analysiswith case-insensitive key lookup - All conditions must pass (AND logic) for the webhook to fire
- Boolean string comparison:
"true"matchestrue,"false"matchesfalse - When
contact.analysisisnull(not yet available):not_existsreturns true (field is indeed missing)neqreturns true (a missing field is not equal to any value)- All other operators (
eq,exists,contains,gt,lt) return false
- Unmet conditions cause the webhook to be silently skipped (no error, no delivery log entry)
Condition Priority (with Campaign Webhooks)
When a webhook is associated with a campaign via CampaignWebhook, conditions are evaluated in priority order:
- Campaign-level conditions (from
CampaignWebhook.conditions) if present, these are used exclusively - Endpoint-level conditions (from
WebhookEndpoint.conditions) used when no campaign association or no campaign-level conditions - Unconditional fires if no conditions exist at either level
Campaign-Webhook Associations
The CampaignWebhook model links webhook endpoints to specific campaigns with optional condition overrides.
Why Campaign Associations?
- Same endpoint can behave differently per campaign (different conditions)
- Campaign managers can override when webhooks fire without modifying the endpoint itself
- Enables per-campaign conditional logic on shared webhook infrastructure
Data Model
{
"campaignWebhookId": "cwh_abc123",
"webhookId": "whk_xyz789",
"campaignUID": "D-NzcJnBLqnx",
"customerUID": "cust_123",
"conditions": [
{ "field": "is_interested", "operator": "eq", "value": true }
],
"isActive": true,
"deletedAt": null
}Maximum 10 conditions per campaign-webhook association.
CRM API Call Behavior
Contacts with campaignUID starting with "api_call_" (CRM API-triggered calls) skip campaign-webhook condition overrides and use only the endpoint-level conditions. This ensures CRM integrations behave predictably regardless of campaign associations.
API Examples
Associate webhook with campaign:
POST /campaign-webhooks
{
"webhookId": "whk_xyz789",
"campaignUID": "D-NzcJnBLqnx",
"conditions": [
{ "field": "sentiment", "operator": "eq", "value": "positive" },
{ "field": "is_interested", "operator": "eq", "value": true }
]
}List associations for a campaign:
GET /campaign-webhooks?campaignUID=D-NzcJnBLqnxUpdate conditions:
PUT /campaign-webhooks/cwh_abc123
{
"conditions": [
{ "field": "lead_score", "operator": "gt", "value": 7 }
],
"isActive": true
}Remove association:
DELETE /campaign-webhooks/cwh_abc123Real Response Example call.completed
This is a real payload delivered when a campaign call completes with all data points. The analysis field contains the results from your custom analysis schema whatever fields you configure in your analysis schema will appear here.
HTTP Headers (Standard Mode)
Content-Type: application/json
X-Oktivo-Signature: e4e0cca4d79fdea923a19d51cac93b5d564d25f092ebfee4e99f0bdb067453a2
X-Oktivo-Event: call.completed
X-Oktivo-Delivery-Id: dlv_MP5Oiat_oyt7W5sP
X-Oktivo-Timestamp: 1775575802Payload
{
"id": "ETD1RtRBREAj:call.completed",
"version": "1.0",
"type": "call.completed",
"timestamp": "2026-04-07T15:30:02.733Z",
"data": {
"contactUID": "ETD1RtRBREAj",
"phone": "+919988778977",
"campaignUID": "D-NzcJnBLqnx",
"agentId": "WtJXFwdffRPU",
"orgUID": "org_F7N5oJv8Lvf271NM",
"callStatus": "ended_by_user",
"disconnectReason": "CLIENT_INITIATED",
"billingDuration": 67,
"callDuration": 67,
"participantJoinedAt": "2026-04-07T15:28:24.979Z",
"participantLeftAt": "2026-04-07T15:29:37.006Z",
"callAnsweredAt": "2026-04-07T15:28:30.559Z",
"attempts": 2,
"transcription": {
"items": [
{
"type": "message",
"role": "assistant",
"content": ["नमस्ते, मैं Khopoli Location से पूजा बात कर रही हूँ..."],
"metrics": { "started_speaking_at": 1775575711.39 }
},
{
"type": "message",
"role": "user",
"content": ["हां जी बताइए क्या कौन सा plot है?"],
"transcript_confidence": 0.90
}
]
},
"analysis": {
"is_interested": true,
"sentiment": "positive",
"summary": "User is interested in premium residential plots in Khopoli..."
},
"recordingUrl": "https://oktivo-recordings.s3.ap-south-1.amazonaws.com/recordings/org_F7N5oJv8Lvf271NM/ETD1RtRBREAj.ogg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-Expires=31536000&X-Amz-Signature=..."
}
}About the analysis field
The analysis object contains results from your custom analysis schema. The fields inside analysis are dynamic they match whatever schema you've configured for your agent's analysis. Common fields include:
is_interestedboolean, whether the contact showed interestsentimentstring, overall call sentimentsummarystring, AI-generated call summary- Any custom fields you define in your analysis schema (e.g.
budget_range,preferred_location,follow_up_needed)
About recordingUrl
The recordingUrl is a presigned S3 URL with 1-year validity. It provides direct access to the recording file without additional authentication.
- Format: Full HTTPS URL with AWS signature query parameters
- Expiry: 1 year (31,536,000 seconds) from generation time
- Access: Direct download no Oktivo backend auth required
- Regeneration: A new presigned URL is generated each time the webhook fires
Previous behavior (deprecated): Recording URLs were previously relative paths (e.g. /recording/ETD1RtRBREAj) requiring the Oktivo backend base URL. All new deliveries use presigned S3 URLs.
Customer Integration Guide
Share this section with organizations setting up webhook integrations.
Step 1: Create a Webhook Endpoint
In the Oktivo dashboard, go to Tools → Webhooks → Add Webhook:
- Select mode: Standard (HMAC-signed) or Custom (full HTTP control)
- Enter your HTTPS URL (HTTP not allowed)
- Select which events you want to receive
- Optionally filter by agents (
agentIds) - Save for Standard mode, you'll get a one-time secret. Copy it immediately.
Step 2: Set Up Your Server
Your server needs to:
- Accept requests at the URL you registered
- Verify the HMAC signature (Standard mode recommended)
- Return 2xx status within 10 seconds
- Process the event asynchronously if it takes longer
Step 3: Verify Signatures (Standard Mode Recommended)
const crypto = require('crypto');
const express = require('express');
const app = express();
app.use(express.json());
const WEBHOOK_SECRET = 'your-64-char-hex-secret-from-dashboard';
function verifySignature(secret, signature, timestamp, rawBody) {
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expected, 'hex')
);
}
app.post('/oktivo-webhook', (req, res) => {
const signature = req.headers['x-oktivo-signature'];
const timestamp = req.headers['x-oktivo-timestamp'];
const eventType = req.headers['x-oktivo-event'];
const deliveryId = req.headers['x-oktivo-delivery-id'];
const rawBody = JSON.stringify(req.body);
// 1. Reject stale deliveries (older than 5 minutes)
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (age > 300) {
return res.status(401).json({ error: 'Timestamp too old' });
}
// 2. Verify HMAC signature
if (!verifySignature(WEBHOOK_SECRET, signature, timestamp, rawBody)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// 3. Process the event
const { type, data } = req.body;
switch (type) {
case 'call.ended':
console.log(`Call ended: ${data.phone}, status: ${data.callStatus}`);
break;
case 'call.transcription_ready':
console.log(`Transcription ready for ${data.phone}`);
// data.transcription.items contains the full conversation
break;
case 'call.analysis_ready':
console.log(`Analysis ready: interested=${data.analysis.is_interested}`);
// data.analysis contains your custom analysis schema results
break;
case 'call.recording_ready':
console.log(`Recording: ${data.recordingUrl}`);
// recordingUrl is a presigned S3 URL direct download, 1-year expiry
break;
case 'call.completed':
// All data available transcription + analysis + recording + call status
console.log(`Call complete: ${data.phone}`);
console.log(` Interested: ${data.analysis?.is_interested}`);
console.log(` Duration: ${data.billingDuration}s`);
console.log(` Summary: ${data.analysis?.summary}`);
console.log(` Recording: ${data.recordingUrl}`);
break;
}
// 4. Return 200 quickly do heavy processing async
res.status(200).json({ received: true });
});
app.listen(3001, () => console.log('Webhook server on :3001'));Python Example
import hmac
import hashlib
import time
import json
from flask import Flask, request, jsonify
app = Flask(__name__)
WEBHOOK_SECRET = 'your-64-char-hex-secret-from-dashboard'
def verify_signature(secret, signature, timestamp, body):
expected = hmac.new(
secret.encode(), f"{timestamp}.{body}".encode(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
@app.route('/oktivo-webhook', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-Oktivo-Signature', '')
timestamp = request.headers.get('X-Oktivo-Timestamp', '')
raw_body = request.get_data(as_text=True)
# Verify age
if abs(time.time() - int(timestamp)) > 300:
return jsonify(error='Timestamp too old'), 401
# Verify signature
if not verify_signature(WEBHOOK_SECRET, signature, timestamp, raw_body):
return jsonify(error='Invalid signature'), 401
event = request.json
event_type = event['type']
data = event['data']
if event_type == 'call.completed':
print(f"Call complete: {data['phone']}")
print(f" Interested: {data.get('analysis', {}).get('is_interested')}")
print(f" Summary: {data.get('analysis', {}).get('summary')}")
# recordingUrl is now a presigned S3 URL download directly
print(f" Recording: {data.get('recordingUrl')}")
return jsonify(received=True), 200Best Practices for Consumers
- Always verify signatures (Standard mode) prevents spoofed payloads
- Check the timestamp reject deliveries older than 5 minutes to prevent replay attacks
- Return 200 quickly do heavy processing asynchronously. If you take >10s, we'll retry
- Handle duplicates use the
idfield for idempotency. Same event = sameid - Ignore unknown fields we may add new fields without bumping the version
- Use
call.completedif you need everything it fires only when all data is ready - Store the secret securely it's shown once. Use "Regenerate Secret" if lost (no need to delete the endpoint)
- Recording URLs are presigned they expire after 1 year, download promptly if archiving
Secret Management
- Secret is shown only once on create and on regenerate
- If you lose it, click "Regenerate Secret" old secret stops working, new one takes over
- All delivery logs are preserved when you regenerate
- No need to delete and recreate the endpoint
- Custom mode endpoints don't use shared secrets (auth is per-endpoint configuration)
API Endpoints
All require JWT + webhooks feature access.
Webhook Endpoints
| Method | Path | Description |
|---|---|---|
POST | /webhooks/endpoints | Create endpoint (returns secret once for standard mode) |
GET | /webhooks/endpoints | List all endpoints |
GET | /webhooks/endpoints/:webhookId | Get single endpoint |
PUT | /webhooks/endpoints/:webhookId | Update endpoint |
DELETE | /webhooks/endpoints/:webhookId | Soft-delete endpoint |
POST | /webhooks/endpoints/:webhookId/regenerate-secret | Regenerate secret (standard mode) |
POST | /webhooks/endpoints/test-delivery | Send a test request to a URL (see below) |
GET | /webhooks/delivery-logs | List logs (paginated, filtered) |
GET | /webhooks/delivery-logs/:deliveryId | Get single log |
GET | /webhooks/template-variables | Available template variables (grouped) |
Test Delivery
POST /webhooks/endpoints/test-delivery mirrors what the worker would actually send.
Standard mode send just the URL and event type:
{ "url": "https://api.mycrm.com/hook", "eventType": "call.completed" }Custom mode send the full request config. The endpoint applies the configured auth (Bearer / API Key / Basic), custom headers, HTTP method and the interpolated body against sample contact data. Without payloadMode: "custom" the test is sent unauthenticated, which is why a bearer-protected endpoint would answer 401 even with a correct configuration.
{
"url": "https://api.mycrm.com/hook",
"eventType": "call.completed",
"payloadMode": "custom",
"method": "POST",
"headers": [{ "key": "X-Source", "value": "oktivo", "enabled": true }],
"bodyType": "json",
"bodyTemplate": "{\"phone\":\"{{phone}}\",\"analysis\":\"{{analysis}}\"}",
"auth": { "type": "bearer", "token": "sk_live_abc123" }
}Campaign-Webhook Associations
| Method | Path | Description |
|---|---|---|
POST | /campaign-webhooks | Associate webhook with campaign |
GET | /campaign-webhooks?campaignUID= | List associations for a campaign |
PUT | /campaign-webhooks/:campaignWebhookId | Update conditions/active status |
DELETE | /campaign-webhooks/:campaignWebhookId | Soft-delete association |
Agent Tools
| Method | Path | Description |
|---|---|---|
GET | /agents/:agentId/tools | List all tools accessible to an agent (webhooks + API actions with isToolEnabled) |
Migration
| Method | Path | Description |
|---|---|---|
POST | /migration/api-actions | Trigger ApiAction → Webhook migration |
GET | /migration/api-actions/status | Get migration progress/status |
Template Variables Endpoint
GET /webhooks/template-variables returns the full list of available variables grouped by category:
{
"success": true,
"data": {
"groups": [
{
"name": "Contact",
"variables": [
{ "key": "phone", "description": "Contact phone number (E.164)" },
{ "key": "name", "description": "Contact name" },
{ "key": "contactUID", "description": "Unique contact identifier" },
{ "key": "campaignUID", "description": "Campaign identifier" },
{ "key": "agentId", "description": "Agent identifier" },
{ "key": "orgUID", "description": "Organization identifier" }
]
},
{
"name": "Call Data",
"variables": [
{ "key": "callStatus", "description": "Final call status" },
{ "key": "disconnectReason", "description": "Why the call ended" },
{ "key": "billingDuration", "description": "Billable duration in seconds" },
{ "key": "callDuration", "description": "Total call duration in seconds" },
{ "key": "attempts", "description": "Number of call attempts" },
{ "key": "recordingUrl", "description": "Presigned S3 URL (1-year expiry)" }
]
},
{
"name": "Analysis",
"variables": [
{ "key": "summary", "description": "AI-generated call summary" },
{ "key": "sentiment", "description": "Call sentiment" },
{ "key": "analysis", "description": "Full analysis object (JSON)" }
]
},
{
"name": "Conversation",
"variables": [
{ "key": "transcription", "description": "Full transcription (JSON)" }
]
},
{
"name": "CSV Data",
"variables": [
{ "key": "dynamicVariables", "description": "Agent template variables from CSV (JSON)" },
{ "key": "extraCsvData", "description": "Extra CSV columns not in agent template (JSON)" }
]
}
]
}
}Security
- Secrets: 256-bit random, bcrypt-hashed in MongoDB, AES-256-GCM encrypted in Redis
- Signing: HMAC-SHA256 over
{timestamp}.{body}prevents tampering and replay attacks (standard mode) - Secret shown only on create and regenerate never again
- HTTPS-only endpoint URLs enforced
- Tenant isolation: all queries scoped to
customerUID - Atomic creation: if encryption or Redis caching fails, the MongoDB document is rolled back
- Custom mode auth secrets (Bearer tokens, API keys, passwords) encrypted at rest with AES-256-GCM
- Auth secrets masked in API responses only first 4 chars +
**** agentIdsvalidated against customer ownership on create/update
File Map
services/webhook.service.ts Core logic: helpers, BullMQ queue/worker, enqueueEvent, condition evaluation, payload mode routing
controllers/webhook-management.controller.ts CRUD API handlers for endpoints
controllers/webhook.controller.ts Lifecycle hooks (participant_left, transcriptionSync, etc.)
controllers/campaign-webhook.controller.ts Campaign-webhook association CRUD
routes/webhook-management.routes.ts Endpoint route definitions
routes/campaign-webhook.routes.ts Campaign-webhook route definitions
models/WebhookEndpoint.ts Mongoose model for endpoints (updated: agentIds, payloadMode, custom fields)
models/WebhookDeliveryLog.ts Mongoose model for delivery logs (30-day TTL)
models/CampaignWebhook.ts Mongoose model for campaign-webhook associations
types/index.ts EVENT_TYPES, interfaces, feature gate entries
types/webhook.types.ts Webhook-specific TypeScript types (conditions, payloadMode, auth)
utils/uid.utils.ts generateWebhookId(), generateDeliveryId(), generateCampaignWebhookId()
services/migration.service.ts ApiAction → Webhook migration logic
scripts/migrate-agentId-to-agentIds.ts One-time migration: agentId (single) → agentIds (array)
server.ts initWebhookWorker() / closeWebhookWorker() lifecycle