Webhooks
Receive signed call events in Zapier, Make, n8n or your own endpoint.
Webhooks send a signed event to a URL you choose every time a call completes. Use them to feed call data into Zapier, Make, n8n or your own systems — for example to update a spreadsheet, alert a client in Slack, or push leads into a CRM we don’t integrate with directly.
Plan: Requires a plan that includes integrations — by default Agency and Agency Pro.
- An endpoint that accepts HTTPS POST requests on a public address.
- An agency owner, agency admin or client admin account.
How webhooks work#
Webhooks are outbound only: we send (POST) events to you. There is no inbound API to call Wozena from your systems.
A webhook needs two pieces:
- A Webhook integration that holds your URL and signing secret.
- An automation that decides which calls send an event. See Post-call automations.
The only event is call.completed. It fires once the call has ended and its post-call analysis is ready.
Set up a webhook#
- Switch into the clientWebhooks, like other integrations, belong to a workspace. Connect it for the client (choose the client under “Workspace” when connecting) to send that client’s calls. A webhook connected in agency-wide context is separate — see the warning in Which calls an automation runs on.
- Add the Webhook integrationGo to Integrations and click “Connect” on the Zapier, Make, n8n or Custom webhook tile under “Automation & webhooks”. Enter the “Webhook URL” (a public HTTPS address).
- Add a signing secretEnter a long random string in “Signing secret” and store the same value on your server. It’s optional, but without it we don’t sign requests and you can’t prove they came from us.
- SaveClick “Connect & validate”. We check the URL’s format only; nothing is sent yet.
- Create the automationGo to Automations, click “New automation”, choose the Webhook destination and the “POST every call to a webhook” template. Add conditions if you only want some calls, then click “Create”.
- Make a test callCall the agent, hang up, and wait for the post-call analysis. The event should arrive shortly after. Check “Run history” on the Automations page if it doesn’t.
Request and headers#
Each event is an HTTP POST with content-type: application/json. We wait up to 8 seconds for a response. Respond with a 2xx status; any status of 400 or above counts as a failure. We ignore the response body.
| Header | Value |
|---|---|
x-wozena-event | call.completed |
x-wozena-delivery | A stable id for this call and automation, the same value as event_id in the body. Retries of the same event reuse it, so use it to drop duplicates. |
x-wozena-signature | v=<unix time in ms>,d=<hex digest>. Sent only when a signing secret is saved. |
User-Agent | wozena/1.0 (+https://iwozena.com) |
User-Agent header names the platform, not your brand. If a client can see your raw request logs, they’ll see it there.Payload#
The body is compact JSON (no spaces or line breaks) with keys sorted alphabetically. The example below is formatted for reading, and every value in it is illustrative.
| Field | What it contains |
|---|---|
event | Always call.completed. |
event_id | Unique per call and automation. Same as the x-wozena-delivery header. |
call_id | The call’s id at the voice provider (Retell). |
agent_id | The agent that handled the call. |
client_id | The client the call belongs to. |
direction | inbound or outbound. |
from | The calling number, in E.164 format. |
to | The number called, in E.164 format. |
outcome | successful, unsuccessful, or empty, from the post-call success check. |
sentiment | The caller’s sentiment from post-call analysis. |
duration_ms | Call length in milliseconds. |
transcript | The full transcript as text. |
recording_url | A link to the call recording, when one exists. |
summary | The post-call summary. |
analysis | Every post-call analysis field, including your custom ones (for example appointment_booked). |
timestamp | When the call started, in Unix milliseconds, sent as a string. |
Some fields can be null, for example when a call has no recording or no summary. Handle missing values.
{
"agent_id": "3f1c2a9e-5b7d-4c21-9e0a-7d2b6f4e8a10",
"analysis": {
"appointment_booked": true,
"call_successful": true,
"call_summary": "Caller asked for a cleaning next week and booked Tuesday at 10:00.",
"email": "[email protected]",
"name": "Jamie Rivera",
"user_sentiment": "Positive"
},
"call_id": "call_0a1b2c3d4e5f60718293a4b5",
"client_id": "8c4e7b12-0f3a-4d6e-b5a9-2e1f9c7d6b34",
"direction": "inbound",
"duration_ms": 142000,
"event": "call.completed",
"event_id": "call_0a1b2c3d4e5f60718293a4b5:6d2f8e41-7a9c-4b3e-8f15-0c9a2b7e4d58",
"from": "+14155550123",
"outcome": "successful",
"recording_url": "https://recordings.example.com/call_0a1b2c3d4e5f60718293a4b5.wav",
"sentiment": "Positive",
"summary": "Caller asked for a cleaning next week and booked Tuesday at 10:00.",
"timestamp": "1790517600000",
"to": "+14155550199",
"transcript": "Agent: Thanks for calling Acme Dental, how can I help?\nUser: I'd like to book a cleaning..."
}Verify the signature#
When you save a signing secret, every request carries an x-wozena-signature header like v=1790517600123,d=9f2c…:
vis the time we signed the request, in Unix milliseconds.dis a hex-encoded HMAC-SHA256 digest. The key is your signing secret. The message is the raw request body bytes followed immediately by thevvalue as a decimal string.
To verify, recompute the digest from the raw body and compare it in constant time. Reject the request if the timestamp is more than 5 minutes from your server’s clock.
Node.js
import crypto from "node:crypto";
// rawBody: a Buffer with the exact request body.
// Do not JSON.parse and re-stringify it before verifying.
export function verifyWozena(rawBody, header, secret, toleranceMs = 5 * 60 * 1000) {
if (!header) return false;
let ts;
let digest;
for (const part of header.split(",")) {
const i = part.indexOf("=");
const key = part.slice(0, i).trim();
const value = part.slice(i + 1);
if (key === "v") ts = Number.parseInt(value, 10);
else if (key === "d") digest = value;
}
if (!Number.isFinite(ts) || !digest) return false;
// Reject old or replayed requests.
if (Math.abs(Date.now() - ts) > toleranceMs) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(Buffer.concat([rawBody, Buffer.from(String(ts))]))
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(digest, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Example with Express: keep the raw body for this route.
// app.post("/wozena", express.raw({ type: "application/json" }), (req, res) => {
// if (!verifyWozena(req.body, req.get("x-wozena-signature"), process.env.WOZENA_SIGNING_SECRET)) {
// return res.sendStatus(401);
// }
// const event = JSON.parse(req.body.toString("utf8"));
// res.sendStatus(200); // respond fast, then process the event
// });Python
import hashlib
import hmac
import time
def verify(raw_body: bytes, header: str | None, secret: str, tolerance_ms: int = 300_000) -> bool:
"""raw_body must be the exact request bytes, not re-serialized JSON."""
if not header:
return False
ts = digest = None
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "v":
ts = int(value)
elif key == "d":
digest = value
if ts is None or not digest:
return False
# Reject old or replayed requests.
if abs(int(time.time() * 1000) - ts) > tolerance_ms:
return False
expected = hmac.new(secret.encode(), raw_body + str(ts).encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, digest)
# Example with Flask:
# @app.post("/wozena")
# def wozena():
# if not verify(request.get_data(), request.headers.get("x-wozena-signature"), SIGNING_SECRET):
# abort(401)
# event = request.get_json()
# return "", 200Best practices#
- Always set a signing secret and reject requests that fail verification.
- Reject old timestamps. The examples reject anything more than 5 minutes old, which blocks replayed requests. Keep your server clock in sync.
- Deduplicate on
x-wozena-delivery. A retry can deliver an event you already processed. - Respond with 2xx quickly. We wait up to 8 seconds. Acknowledge first, then do slow work (CRM writes, emails) in the background.
- Expect retries. Any status of 400 or above is a failure. Rate limits and server errors are retried briefly. A failed delivery is then retried after 1, 5 and 15 minutes — 4 attempts in total. Each retry is signed again with a fresh timestamp.
- Replay after an outage. Fix your endpoint, then replay failed runs from “Run history”. See Run history, retries and replay.
Zapier, Make and n8n#
You don’t need a server to use webhooks. In any automation tool that accepts incoming webhooks:
- Create a webhook triggerAdd a Catch Hook or Webhook trigger in your tool and copy the URL it gives you.
- Paste the URL into WozenaUse it as the “Webhook URL” when you connect the Webhook integration.
- Send a sampleMake a test call so the tool receives a real event and can show you the fields.
- Build the rest of the flowMap fields such as
from,summaryand the values insideanalysisto the next steps.
Most no-code tools can’t check the signature. If your tool can’t, treat the trigger URL as a secret and don’t share it.
Troubleshooting#
| What you see | What to do |
|---|---|
| “A public HTTPS webhook URL is required” | Use an address that starts with https:// and is reachable from the internet. |
| “Webhook destination must be public” | Localhost and private network addresses aren’t allowed. Expose the endpoint publicly or use a tunnel with a public HTTPS address. |
| “Webhook destination must be a public hostname” | Use a full domain name such as hooks.example.com, not a single-word host name. |
| “Invalid webhook port” | Remove the unusual port, or use a standard HTTPS port. |
| URL rejected for another reason | Remove any username or password from the URL (user:pass@) and any #fragment at the end. |
No x-wozena-signature header | No signing secret is saved on the Webhook connection. Add one with “Rotate token” on the connection card. |
| Signature never matches | You’re hashing parsed or re-serialized JSON, the secret differs, or your clock is more than 5 minutes off. |
| No events arrive | Check the automation is Active, its conditions match (try a dry run), and it lives in the same workspace as the calls. Then check “Run history” with “Failed only” on. |
| The same event arrives twice | A retry followed a slow or failed response. Deduplicate on x-wozena-delivery and respond faster. |