Webhooks and n8n Integration
Get notified the instant a call ends. TTMA pushes a structured JSON payload to your URL after every call, so you can log results, notify your team, update your CRM, or trigger any downstream workflow without polling.
What webhooks do
Every time a call finishes on your TTMA voice agent, the platform fires a call.ended event. Instead of polling the Call Results API on a timer, you register a URL and TTMA POSTs the full call data to it within seconds of hangup - transcript, duration, caller number, task results, recording availability, all delivered straight to your server or automation tool. This is the foundation for logging calls to a spreadsheet, pinging Slack, syncing outcomes to your CRM, or sending a follow-up email before the caller forgets who they spoke with.
Setting up your webhook
Configure your webhook with a single PUT request. You need an API key with the voice:call scope.
PUT https://api.talktomyagent.io/v1/voice/webhooks
Authorization: Bearer mbn_live_...
Content-Type: application/json
{
"from": "+16473705600",
"url": "https://your-server.com/webhooks/ttma",
"secret": "your-signing-secret-at-least-32-characters-long",
"enabled": true
}Requirements
Your webhook URL must meet these conditions:
- Must use HTTPS (plain HTTP is rejected)
- Must not point to private IP ranges (10.x, 172.16-31.x, 192.168.x) or localhost
- The signing secret must be at least 32 characters long
- Only one webhook URL per phone number (PUT overwrites the previous config)
Your secret is stored securely and never returned in full. The API only shows the last 4 characters so you can identify which secret is active.
n8n integration step-by-step
n8n is a workflow automation tool that can receive webhooks natively. Here is how to wire it up to TTMA in under five minutes.
1. Create a Webhook node in n8n
In your n8n canvas, add a Webhook node. Set the HTTP method to POST. Copy the production webhook URL that n8n generates (it looks like https://your-n8n.app.n8n.cloud/webhook/abc123).
2. Register the URL with TTMA
Use the PUT endpoint above, pasting your n8n webhook URL as the url field. Pick a strong secret and save it somewhere safe - you will need it in the next step.
3. Verify the HMAC signature in n8n
Add a Code node after your Webhook node. Paste the verification logic below (adapted for n8n's JavaScript environment):
// n8n Code node - verify TTMA webhook signature
const crypto = require("crypto");
const SECRET = "your-signing-secret-at-least-32-characters-long";
const signature = $input.first().headers["x-ttma-signature"];
const rawBody = JSON.stringify($input.first().json);
const parts = {};
signature.split(",").forEach(p => {
const [k, v] = p.split("=");
parts[k] = v;
});
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (age > 300) throw new Error("Webhook too old (possible replay)");
const expected = crypto
.createHmac("sha256", SECRET)
.update(parts.t + "." + rawBody)
.digest("hex");
if (expected !== parts.v1) throw new Error("Invalid HMAC signature");
return $input.all();If you are using n8n Cloud with a static URL, you can skip signature verification for testing. But always verify in production to prevent forged payloads from triggering your workflows.
4. Process the payload
After verification passes, the call data is available as structured JSON. Access any field directly in subsequent nodes: {{ $json.data.transcript }}, {{ $json.data.durationSeconds }}, {{ $json.data.task.goal }}.
Payload explained
Every call.ended delivery includes this structure:
{
"event": "call.ended",
"deliveryId": "a1b2c3d4-e5f6-...",
"timestamp": "2026-05-25T14:35:12.000Z",
"data": {
"id": "v3:abc123...",
"direction": "outbound",
"status": "completed",
"endedReason": "botHangup",
"callerNumber": "+16473705600",
"calledNumber": "+16475551234",
"startedAt": "2026-05-25T14:30:00.000Z",
"endedAt": "2026-05-25T14:35:12.000Z",
"durationSeconds": 312,
"recordingAvailable": true,
"hasTranscript": true,
"transcript": "[Agent] Hi, this is Sol from...",
"task": {
"goal": "Collect payment from Bobby",
"script": "debt-collection",
"facts": { "debtor_name": "Bobby", "amount_due": "$154.56" },
"escalation": null
},
"usage": { "turnCount": 8 },
"quality": { "grade": "A" }
}
}Key fields
| Field | Description |
|---|---|
event | Always "call.ended" (more event types coming soon) |
deliveryId | Unique per delivery - use for deduplication |
data.id | The call ID (same as returned by the outbound API) |
data.direction | "inbound" or "outbound" |
data.durationSeconds | Total call length in seconds |
data.callerNumber | The phone number that placed the call |
data.calledNumber | The phone number that received the call |
data.transcript | Full conversation text (if Ship Transcripts is on) |
data.recordingAvailable | Whether an MP3 recording can be fetched |
data.task | Task goal, script, and extracted facts (outbound only) |
For inbound calls, task is null. The transcript field requires the Ship Transcripts toggle to be enabled in your voice portal settings.
HMAC signature verification
Every delivery includes an X-TTMA-Signature header with the format t=1716648912,v1=abc123.... Here is how verification works:
- Parse the header to extract the timestamp (
t) and signature (v1) - Check the timestamp is within 5 minutes of now (replay defense)
- Compute
HMAC-SHA256(secret, t + "." + rawBody) - Compare your computed hex digest to
v1using constant-time comparison
// Node.js / Express example
const crypto = require("crypto");
app.post("/webhooks/ttma", (req, res) => {
const sig = req.headers["x-ttma-signature"];
const parts = {};
sig.split(",").forEach(p => {
const [k, v] = p.split("=");
parts[k] = v;
});
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (age > 300) return res.status(401).send("Stale");
const expected = crypto
.createHmac("sha256", process.env.TTMA_WEBHOOK_SECRET)
.update(parts.t + "." + req.rawBody)
.digest("hex");
const valid = crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(parts.v1, "hex")
);
if (!valid) return res.status(401).send("Bad signature");
// Signature valid - process the event
const { data } = req.body;
console.log("Call ended:", data.id, data.durationSeconds + "s");
res.sendStatus(200);
});Always use timingSafeEqual (or equivalent) instead of === to prevent timing attacks on the signature comparison.
Practical recipes
Log every call to Google Sheets
Webhook node, then a Google Sheets node that appends a row. Map columns: Date = {{ $json.timestamp }}, Caller = {{ $json.data.callerNumber }}, Duration = {{ $json.data.durationSeconds }}, Outcome = {{ $json.data.endedReason }}, Transcript = {{ $json.data.transcript }}. Every call is logged within seconds of hangup.
Send a Slack notification after each call
Webhook node, then a Slack node posting to your channel. Template the message: "Call with {{ $json.data.calledNumber }} lasted {{ $json.data.durationSeconds }}s. Outcome: {{ $json.data.endedReason }}." Add an IF node before Slack to only notify on calls longer than 60 seconds or when the quality grade drops below B.
Trigger a CRM update
After verification, use an HTTP Request node to POST to your CRM's API. Pass the caller number as the contact identifier, the task.facts as custom fields, and the transcript as a note. HubSpot, Pipedrive, and Monday.com all accept this pattern.
Auto-send follow-up email after outbound calls
Add an IF node checking {{ $json.data.direction }} === "outbound" and {{ $json.data.endedReason }} === "botHangup" (a successful completed call). Then wire a Send Email node with a template that references the task goal and any facts extracted during the call. Your prospect gets a follow-up email within a minute of hanging up.
Troubleshooting
Webhook not firing
Check that enabled is true in your webhook config. If your endpoint previously returned 410 Gone, TTMA auto-disables the webhook. Re-enable with another PUT request. Also confirm your API key has the voice:call scope, not just voice:read.
HMAC signature mismatch
The most common cause is verifying against a parsed JSON body instead of the raw request body. The signature is computed over the exact bytes sent, so you must use the raw string (before JSON.parse). In Express, use a verify callback on express.json() to preserve req.rawBody. In n8n, use JSON.stringify($input.first().json) as shown above.
Retry behavior
If your endpoint returns a non-2xx status or times out (10-second limit), TTMA retries up to 5 times with increasing backoff. Delivery status moves through waiting, delivering, and exhausted. A background sweep runs every minute to pick up queued retries.
Delayed delivery (waiting for recording)
If call recording is enabled, TTMA waits up to 60 seconds for the recording to become available before dispatching. If it does not arrive in time, the webhook fires anyway with recordingAvailable: false. You will not miss events due to a slow recording upload.