Automating Your CRM with WFW Webhooks: A Practical Guide
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.
ngrok — ngrok 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.
Ready to put AI voice agents to work in your business?
Get a Live Demo — It's FreeContinue Reading
Related Articles
The Last Manual System Prompt
Every voice AI deployment starts with writing a system prompt — hours of work that immediately starts to decay. Here's what we're automating, what stays human, and why the last prompt you write by hand is the one at setup.
The Four-Stage Automation Arc (And Which Stage Your Business Is On)
A framework for understanding where businesses are in their AI automation journey — and where the real value inflection happens.