Skip to content

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 version field will be bumped (e.g. "1.1", "2.0") before any breaking change
  • New fields may be added to data without 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 type field 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 ​

EventFires When
call.endedCall disconnects (participant_left webhook processed)
call.transcription_readyTranscription stored on Contact (POST /transcription-sync)
call.analysis_readyAnalysis stored on Contact (direct MongoDB write by Analysis Backend, notified via POST /analysis-ready) includes custom analysis schema results
call.recording_readyRecording URL set on Contact (egress_ended processed, S3 verified)
call.completedAll 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 ​

  1. Lifecycle hook calls webhookService.enqueueEvent() (fire-and-forget, never blocks)
  2. Service checks feature access → skips silently if webhooks feature not enabled
  3. Skips if contact.isTestCall === true
  4. Queries active, non-deleted endpoints matching customerUID + eventType
  5. Agent filtering filters out endpoints where agentIds is non-empty and does not include contactData.AgentID
  6. Campaign webhook lookup queries CampaignWebhook associations for matching campaignUID + webhookId
  7. Condition evaluation evaluates conditions (campaign-level overrides > endpoint-level > unconditional)
  8. Payload mode routing routes to standard (HMAC) or custom (interpolated HTTP) delivery path
  9. Checks deduplication skips if eventId already delivered successfully
  10. Adds one BullMQ job per matching endpoint

Worker picks up job (Standard mode):

  1. Reads encrypted secret from Redis (webhook:secret:{webhookId})
  2. Decrypts with AES-256-GCM
  3. Computes HMAC-SHA256 signature over {timestamp}.{jsonBody}
  4. HTTP POST with headers: X-Oktivo-Signature, X-Oktivo-Event, X-Oktivo-Delivery-Id, X-Oktivo-Timestamp
  5. 2xx → success, reset failure counter
  6. Non-2xx/timeout → failed, increment failure counter, retry with backoff

Worker picks up job (Custom mode):

  1. Resolves {{variable}} placeholders in URL, headers, and body template from contact data
  2. Applies configured auth (Bearer / API Key / Basic Auth)
  3. Sends HTTP request with configured method (GET, POST, PUT, PATCH, DELETE)
  4. Uses endpoint-specific timeout (timeoutMs, default 10000ms)
  5. 2xx → success
  6. Non-2xx/timeout → failed, retry if retryOnFailure enabled

Body interpolation is content-type aware ​

Interpolation is chosen by bodyType, so templated values can never corrupt the request:

bodyTypeInterpolation
jsonJSON-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.
formValues are percent-encoded; the a={{x}}&b={{y}} structure is preserved.
rawVerbatim substitution.
noneNo 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 ​

AttemptDelay
110 seconds
230 seconds
31 minute
45 minutes
515 minutes
630 minutes
71 hour
82 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 ​

  1. When creating/updating an endpoint, pass an optional agentIds array (max 50 agent IDs)
  2. Deduplication: duplicate agent IDs are removed on save
  3. In enqueueEvent(), after querying active endpoints, the service filters out endpoints where agentIds is non-empty and does not include contactData.AgentID
  4. 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:

json
{
  "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):

json
{
  "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:

ModeBehavior
standardFixed versioned payload, HMAC-SHA256 signed (original behavior)
customFull 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 ​

FieldTypeDescription
payloadMode"standard" | "custom"Delivery mode (default: "standard")
methodenumHTTP method: GET, POST, PUT, PATCH, DELETE (custom mode only)
headersarrayCustom headers [{key, value, enabled}]
bodyTypeenumjson, form, raw, none
bodyTemplatestringBody with {{variable}} placeholders
authobject{type, token, headerName, username, password}
conditionsarrayConditional execution rules (max 20)
timeoutMsnumberRequest timeout in ms (1000–30000, default 10000)
retryOnFailurebooleanEnable retries for custom mode
maxRetriesnumberMax retry attempts (0–10, default 3)

Auth Configuration ​

json
{
  "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.

VariableSource
{{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 ​

json
{
  "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 ​

json
{
  "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 ​

OperatorDescriptionExample
eqEquals (case-insensitive for strings){"field": "sentiment", "operator": "eq", "value": "positive"}
neqNot equals (also true if field is missing){"field": "callStatus", "operator": "neq", "value": "no_answer"}
existsField exists and is not null/undefined{"field": "budget_range", "operator": "exists"}
not_existsField is null/undefined/missing{"field": "budget_range", "operator": "not_exists"}
containsString contains substring (case-insensitive){"field": "summary", "operator": "contains", "value": "interested"}
gtGreater than (numeric){"field": "lead_score", "operator": "gt", "value": 7}
ltLess than (numeric){"field": "lead_score", "operator": "lt", "value": 3}

Evaluation Rules ​

  • Fields are resolved from contact.analysis with case-insensitive key lookup
  • All conditions must pass (AND logic) for the webhook to fire
  • Boolean string comparison: "true" matches true, "false" matches false
  • When contact.analysis is null (not yet available):
    • not_exists returns true (field is indeed missing)
    • neq returns 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:

  1. Campaign-level conditions (from CampaignWebhook.conditions) if present, these are used exclusively
  2. Endpoint-level conditions (from WebhookEndpoint.conditions) used when no campaign association or no campaign-level conditions
  3. 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 ​

json
{
  "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:

json
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-NzcJnBLqnx

Update conditions:

json
PUT /campaign-webhooks/cwh_abc123
{
  "conditions": [
    { "field": "lead_score", "operator": "gt", "value": 7 }
  ],
  "isActive": true
}

Remove association:

DELETE /campaign-webhooks/cwh_abc123

Real 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: 1775575802

Payload ​

json
{
  "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_interested boolean, whether the contact showed interest
  • sentiment string, overall call sentiment
  • summary string, 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:

  1. Accept requests at the URL you registered
  2. Verify the HMAC signature (Standard mode recommended)
  3. Return 2xx status within 10 seconds
  4. Process the event asynchronously if it takes longer
javascript
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 ​

python
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), 200

Best Practices for Consumers ​

  1. Always verify signatures (Standard mode) prevents spoofed payloads
  2. Check the timestamp reject deliveries older than 5 minutes to prevent replay attacks
  3. Return 200 quickly do heavy processing asynchronously. If you take >10s, we'll retry
  4. Handle duplicates use the id field for idempotency. Same event = same id
  5. Ignore unknown fields we may add new fields without bumping the version
  6. Use call.completed if you need everything it fires only when all data is ready
  7. Store the secret securely it's shown once. Use "Regenerate Secret" if lost (no need to delete the endpoint)
  8. 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 ​

MethodPathDescription
POST/webhooks/endpointsCreate endpoint (returns secret once for standard mode)
GET/webhooks/endpointsList all endpoints
GET/webhooks/endpoints/:webhookIdGet single endpoint
PUT/webhooks/endpoints/:webhookIdUpdate endpoint
DELETE/webhooks/endpoints/:webhookIdSoft-delete endpoint
POST/webhooks/endpoints/:webhookId/regenerate-secretRegenerate secret (standard mode)
POST/webhooks/endpoints/test-deliverySend a test request to a URL (see below)
GET/webhooks/delivery-logsList logs (paginated, filtered)
GET/webhooks/delivery-logs/:deliveryIdGet single log
GET/webhooks/template-variablesAvailable 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:

json
{ "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.

json
{
  "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 ​

MethodPathDescription
POST/campaign-webhooksAssociate webhook with campaign
GET/campaign-webhooks?campaignUID=List associations for a campaign
PUT/campaign-webhooks/:campaignWebhookIdUpdate conditions/active status
DELETE/campaign-webhooks/:campaignWebhookIdSoft-delete association

Agent Tools ​

MethodPathDescription
GET/agents/:agentId/toolsList all tools accessible to an agent (webhooks + API actions with isToolEnabled)

Migration ​

MethodPathDescription
POST/migration/api-actionsTrigger ApiAction → Webhook migration
GET/migration/api-actions/statusGet migration progress/status

Template Variables Endpoint ​

GET /webhooks/template-variables returns the full list of available variables grouped by category:

json
{
  "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 + ****
  • agentIds validated 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

Last updated: