Skip to main content

Command Palette

Search for a command to run...

Hooking Bolna voice calls up to Slack

Updated
5 min read
T
DevRel @SuprSend

Voice AI agents make calls. People want to know what got said. The dashboard is fine if you remember to check it, but most teams already live in Slack, so the obvious move is to fire an alert there the moment a call wraps up.

This walks through how I built that for Bolna in an afternoon, the dead ends I hit, and the design choices that survived.

The shape of the problem

Bolna has a webhook setting on every agent (Analytics tab, "Push all execution data to webhook"). When an execution finishes, Bolna POSTs the full execution record to whatever URL you put there. The payload mirrors the Get Execution API response, which includes:

  • id (the execution UUID)

  • agent_id

  • conversation_duration (seconds, float)

  • transcript

  • status (one of fifteen lifecycle values)

  • plus a pile of telephony, cost, and timing fields I did not need

Slack offers Incoming Webhooks: create one, get a hooks.slack.com/services/... URL, POST JSON to it, message appears. Forty lines of code in the middle, at most.

The stack

I started with FastAPI out of habit, then ripped it out. FastAPI pulls in pydantic, pydantic pulls in pydantic-core, pydantic-core is Rust, and Python 3.14 wheels did not exist on the machine I was deploying to. The error was a wall of pyo3-ffi build noise telling me to install a Rust toolchain to ship a thirty-line webhook. No thank you.

Flask handles this in fewer lines, has no compiled dependencies, and installs cleanly on any 3.10+ Python including 3.14. The whole requirements file:

flask>=3.0
httpx>=0.28.1
python-dotenv>=1.0.1

That is it.

The receiver

The endpoint is boring on purpose. Read the JSON, pull four fields, decide whether to forward, build a Slack message, POST it.

@app.post("/bolna/webhook")
def bolna_webhook():
    slack_url = os.getenv("SLACK_WEBHOOK_URL", "").strip()
    secret_error = _check_secret()
    if secret_error:
        return secret_error

    payload = request.get_json(silent=True)
    fields = _extract_fields(payload)

    if not fields["id"] or not fields["agent_id"]:
        return {"ok": False, "reason": "missing id or agent_id"}

    if fields["status"] not in NOTIFY_STATUSES:
        return {"ok": True, "skipped": True}

    send_to_slack(slack_url, build_slack_payload(**fields))
    return {"ok": True, "id": fields["id"]}

The interesting bits are in _extract_fields. Bolna's webhook payload sometimes nests the execution under data or execution depending on the event variant, so I check those before falling back to the root:

body = payload
for key in ("execution", "data"):
    nested = payload.get(key)
    if isinstance(nested, dict) and "id" in nested:
        body = nested
        break

Duration looks for conversation_duration first, then duration, then telephony_data.duration. Different code paths in Bolna serialize it differently, and I would rather be liberal in what I accept than miss a field for a stylistic reason.

The Slack message

A Slack message can be a one-liner with a text field, or it can use Block Kit for richer layouts. I went with Block Kit because the four required fields fit naturally into a header plus a fields grid plus a code-fenced transcript:

  • Header: "Bolna call ended: completed"

  • A two-by-two fields grid for Call ID, Agent ID, Duration, Status

  • Divider

  • Transcript inside a code block

Two gotchas worth knowing:

  1. Slack caps section text at 3000 characters. Transcripts can be much longer, so I truncate at 2800 and append "... (truncated)". I hit the cap once during testing and got a cryptic invalid_blocks back with no hint about which field was at fault.

  2. Durations come in as floats. 73.4 seconds is unhelpful in a notification. Formatted as 1m 13s, it reads instantly.

The status filter

Bolna fires webhooks for the whole lifecycle, not just on call end. I do not want a Slack ping every time a call transitions from queued to initiated to ringing to in-progress. The receiver checks status against a configurable allowlist that defaults to the terminal states:

completed,call-disconnected,failed,no-answer,busy,canceled,stopped,error

Anything else gets a 200 OK with {"skipped": true} and no Slack message. The default covers what most people want, and the env var is there for anyone who wants to be louder or quieter.

The debugging

End-to-end testing this is a four-actor play (your laptop, ngrok, Bolna's servers, Slack), and three of them can fail silently. The two errors that ate the most time:

ERR_NGROK_8012: Bolna delivered, ngrok received, Flask was not running. The fix is keeping a separate terminal pinned to python app.py and never closing it. Closing the terminal to "free up your shell" breaks the chain instantly.

failed to deliver to Slack: my .env still had the placeholder SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T000/B000/XXXX. Slack obediently returned 404 to a fake URL. Replaced the value, restarted Flask (env files only load at boot), message landed.

Both are obvious in hindsight. The ngrok inspector at http://localhost:4040 is what saved me. It shows every incoming request, the source IP (Bolna's is 13.203.39.153, useful for whitelisting at the network layer), and the response Flask returned. If you build webhook integrations and are not running the inspector, you are debugging blind.

What I left out

No retries. No persistence. No HMAC signature check (Bolna does not sign requests, only IP pins). No queue. No metrics. All deliberate. The whole thing is one HTTP request that takes under a second, and Bolna retries on failure. The day this needs to handle thousands of calls per minute is the day to add a queue. Until then, the Flask dev server and a 170-line codebase get the job done.

Wiring it up

Three terminals running:

  1. python app.py (the receiver, port 8000)

  2. ngrok http 8000 (the public tunnel)

  3. Your normal shell, for everything else

In Bolna's Analytics tab, paste the ngrok HTTPS URL with /bolna/webhook appended. Save. Trigger a call. Hang up. Watch Slack.

That is the whole thing. Code is on GitHub, take what is useful.