AI Voice Agents

Automating Your CRM with WFW Webhooks: A Practical Guide

Workforce Wave

April 17, 20265 min read
#automation#crm#integration#webhooks

A call ends. The patient confirmed their appointment, gave their name, and said they'd need a parking spot. That information now lives in a WFW transcript — and nowhere else — unless you build the bridge to your CRM.

Webhooks are that bridge. WFW emits a signed HTTP POST to your endpoint within seconds of each significant event. This post walks through the events WFW emits, how to verify they're genuine, and a complete example that writes confirmed appointments back to Salesforce.

What WFW Emits

Three events cover the lifecycle of an agent and its calls:

agent.configured — fired when a new agent finishes Workforce Wave provisioning and is ready to receive calls. Includes the agent_id, phone number, and a summary of what Workforce Wave found. Useful for triggering the "your AI receptionist is live" notification to your customer.

call.initiated — fired when an inbound call connects to a WFW agent. Includes callid, agentid, caller number (E.164), and timestamp. Useful for logging call volume or triggering a "call in progress" status in your UI.

call.completed — the most useful event. Fired after the call ends and ElevenLabs processing finishes, typically 15–30 seconds after hang-up. The payload includes the full transcript, structured extractions (intent, outcome, named entities the agent identified), and call metadata (duration, disposition).

The call.completed payload looks like this:

{
  "event": "call.completed",
  "timestamp": "2026-08-14T14:23:07Z",
  "data": {
    "call_id": "call_a8b2c3d4",
    "agent_id": "agt_xyz789",
    "duration_seconds": 142,
    "disposition": "appointment_confirmed",
    "transcript": "Agent: Thanks for calling Ridgeline Dental...",
    "extractions": {
      "patient_name": "Maria Santos",
      "appointment_confirmed": true,
      "appointment_date": "2026-08-20",
      "special_notes": "needs parking accommodation"
    }
  }
}

The extractions object is shaped by the agent's configured extraction schema — what fields to pull from the conversation. You set this when provisioning the agent via the extraction_schema field in POST /v2/agents.

Verifying the Signature

Every WFW webhook includes a X-WFW-Signature header. Don't skip verification — without it, anyone who knows your endpoint URL can send fake events.

The signature format is t={timestamp},v1={hmac} where the HMAC is computed over {timestamp}.{raw_body} using your webhook signing secret.

Node.js:

const crypto = require('crypto');

function verifyWfwSignature(rawBody, signatureHeader, secret) {
  // Parse "t=1723645387,v1=abc123..." format
  const parts = Object.fromEntries(
    signatureHeader.split(',').map(part => part.split('='))
  );
  const timestamp = parts.t;
  const receivedHmac = parts.v1;

  // Compute expected HMAC over "timestamp.rawBody"
  const payload = `${timestamp}.${rawBody}`;
  const expectedHmac = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');

  // Reject replays older than 5 minutes
  const age = Math.abs(Date.now() / 1000 - parseInt(timestamp));
  if (age > 300) throw new Error('Webhook timestamp too old');

  if (!crypto.timingSafeEqual(Buffer.from(receivedHmac), Buffer.from(expectedHmac))) {
    throw new Error('Webhook signature mismatch');
  }
}

Python:

import hmac, hashlib, time

def verify_wfw_signature(raw_body: bytes, signature_header: str, secret: str) -> None:
    parts = dict(pair.split("=", 1) for pair in signature_header.split(","))
    timestamp, received_hmac = parts["t"], parts["v1"]

    payload = f"{timestamp}.{raw_body.decode()}"
    expected_hmac = hmac.new(
        secret.encode(), payload.encode(), hashlib.sha256
    ).hexdigest()

    age = abs(time.time() - int(timestamp))
    if age > 300:
        raise ValueError("Webhook timestamp too old")

    if not hmac.compare_digest(received_hmac, expected_hmac):
        raise ValueError("Webhook signature mismatch")

Use crypto.timingSafeEqual / hmac.compare_digest to prevent timing attacks. Don't use === or ==.

Practical Example: call.completed → Salesforce

Here's a complete handler that takes a confirmed appointment from call.completed and upserts the Salesforce contact:

app.post('/webhooks/wfw', express.raw({ type: 'application/json' }), async (req, res) => {
  // Verify before doing anything else
  try {
    verifyWfwSignature(req.body, req.headers['x-wfw-signature'], process.env.WFW_WEBHOOK_SECRET);
  } catch (err) {
    console.error('Webhook verification failed:', err.message);
    return res.status(401).json({ error: err.message });
  }

  const event = JSON.parse(req.body);

  // Only process confirmed appointments
  if (event.event !== 'call.completed') return res.sendStatus(200);
  if (event.data.disposition !== 'appointment_confirmed') return res.sendStatus(200);

  const { patient_name, appointment_date, special_notes } = event.data.extractions;

  // Upsert Salesforce contact — search by name, create if missing
  await salesforce.upsertContact({
    name: patient_name,
    nextAppointment: appointment_date,
    notes: special_notes,
    lastCallId: event.data.call_id,
  });

  return res.sendStatus(200);
});

Return 200 quickly. WFW treats any non-2xx as a failure and will retry. If your Salesforce call takes 3 seconds, accept the webhook, queue the work, and respond immediately.

No-Code Integration: n8n and Make.com

If you're not writing a custom handler, n8n and Make.com both have native webhook trigger nodes that consume signed webhooks without any code.

In n8n: add a Webhook node as the trigger, paste your WFW endpoint URL into the webhook subscription, and use the Crypto node to verify the HMAC before continuing the workflow. Then add a Salesforce node (or HubSpot, or Airtable) to write the extraction data wherever it belongs.

In Make.com: the Webhooks → Custom webhook module works the same way. Use the Text parser module to extract the signature header, verify the HMAC with the Crypto module, then connect any of Make's 1,000+ app modules.

Both platforms let non-developers build the CRM automation without writing a handler. The signature verification step is the only part that requires care — the HMAC pattern is the same regardless of which tool you use.

Retry Behavior

If your endpoint returns a non-2xx status, WFW retries with exponential backoff: 30 seconds, 2 minutes, 10 minutes, 30 minutes, 2 hours. After 5 failures, the delivery is dropped and logged as failed. After 10 consecutive failures across all deliveries to a given endpoint, WFW opens a circuit breaker and stops attempting delivery until you re-enable the subscription in the dashboard.

This means your endpoint needs to be idempotent — if the same callid arrives twice (retry after a transient failure on your side), updating Salesforce twice shouldn't create two contacts or two appointment records. Use callid as your deduplication key.

Development Tips

Local development complicates webhook testing because WFW can't reach localhost. Two easy options:

webhook.site — paste the generated URL into your webhook subscription, trigger a test call, and inspect the raw payload in the browser. Good for understanding the payload shape before writing any handler code.

ngrokngrok http 3000 gives you a public URL that tunnels to your local server. WFW can reach it, your local handler receives real events, and you can iterate quickly. The signingsecrethint field in the webhook subscription response shows the last 4 characters of your signing secret — use it to confirm you're verifying against the right secret when rotating keys.


Next in this series: Rate Limiting and Idempotency: What Your Bot Needs to Know — the two API patterns every AI consumer of the WFW API needs to implement correctly.

Share this article

Ready to put AI voice agents to work in your business?

Get a Live Demo — It's Free