Post-Call Webhook

Post-Call Transcripts to Your Agent

After every call ends, AgentCall POSTs the full transcript and an LLM-extracted summary to your endpoint. Your agent platform reads the outcome and acts on it: drafts follow-up emails, schedules meetings, updates context for the next call. This is the loop that turns a phone call into something your AI employee can remember.

The Post-Call Loop

A live phone call between you and your AI agent produces a transcript. Without a post-call webhook, that transcript dies in the AgentCall dashboard. With one, your agent platform absorbs it and can take real actions based on what was decided. Same payload powers any pattern: store and analyze later, fire off emails, push to a CRM, update tomorrow's brief.

Call ends
You hang up. AgentCall closes the voice session, finalizes the transcript, and runs it through a fast LLM to extract caller name, intent, urgency, and a 1 to 2 sentence summary.
Webhook fires
AgentCall POSTs a JSON payload to every endpoint subscribed to the call.transcript event, signed with HMAC-SHA256 in the X-AgentCall-Signature header. Failed deliveries are retried automatically.
Your endpoint
Verifies the signature, parses the payload, and hands the transcript to your agent platform. From there: your agent drafts the follow-up email, creates a calendar invite, posts a note to your CRM, whatever the call decided.
Next call
When you call back tomorrow, your agent has yesterday's conversation as context. Decisions don't get repeated. Loose ends don't get dropped.

Payload Schema

Every webhook delivery from AgentCall is wrapped in a standard envelope. The call.transcript event-specific fields live inside the data object. Make sure your parser reads body.data.callId, not body.callId.

POST {your-endpoint}, Content-Type application/json
{
  "event":     "call.transcript",
  "timestamp": "2026-05-13T14:30:15.000Z",
  "data": {
    "callId": "call_xxx",
    "duration": 53,
    "transcript": [
      { "role": "ai",    "text": "Hey, what do you want to dig into?", "timestamp": "2026-05-13T14:30:00.000Z" },
      { "role": "human", "text": "Walk me through this morning.",       "timestamp": "2026-05-13T14:30:04.123Z" },
      { "role": "ai",    "text": "Three updates from your inbox...",    "timestamp": "2026-05-13T14:30:07.456Z" }
    ],
    "summary": {
      "summary":     "Caller wanted a walkthrough of the morning brief and decided to follow up with Sarah at Acme on Wednesday.",
      "callerName":  "David",
      "intent":      "scheduling",
      "urgency":     "medium",
      "callbackBy":  "Wednesday 2 PM CT",
      "spam":        false
    }
  }
}
callId

Stable ID. Same value across the call.transcript webhook, GET /v1/calls/:callId/transcript, and dashboard.

duration

Seconds, measured by the media bridge clock (not the carrier's).

transcript

Array of turns. role is ai or human. Timestamps are ISO 8601 UTC.

summary.intent

One of: service_request, quote_request, scheduling, complaint, spam, general_inquiry, other.

summary.urgency

One of high, medium, low. high means the caller said urgent, today, ASAP, or emergency.

summary.spam

When true, the LLM thinks this is a telemarketer, robocall, wrong number, or hostile caller. Use it to skip downstream actions.

Subscribe

1

Register a webhook

Pick one of three ways. They all produce the same webhook record:

Via MCP (talking to your agent, e.g. Hermes or Claude Desktop):

“Register an AgentCall webhook for call.transcript events pointing at https://my-agent.example.com/agentcall/transcript.”

Your agent calls create_webhook on the AgentCall MCP server. The response includes the signing secret, which is only shown once. Save it.

Via the Node SDK:

import { AgentCall } from 'agentcall'

const client = new AgentCall({ apiKey: process.env.AGENTCALL_API_KEY })

const wh = await client.webhooks.create({
  url:    'https://my-agent.example.com/agentcall/transcript',
  events: ['call.transcript'],
})

console.log(wh.secret) // shown once, store it

Via REST:

curl -X POST https://api.agentcall.co/v1/webhooks \
  -H "Authorization: Bearer ac_live_xxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://my-agent.example.com/agentcall/transcript",
    "events": ["call.transcript"]
  }'
2

Verify the HMAC signature

Every delivery carries an X-AgentCall-Signature header of the form sha256=<hex>. Compute HMAC-SHA256 of the raw request body using the signing secret you saved in Step 1, hex-encode it, prefix with sha256=, and compare with constant-time equality. Reject anything that doesn't match.

Node / TypeScript (Express)
import express from 'express'
import crypto from 'node:crypto'

const app = express()
const SECRET = process.env.AGENTCALL_WEBHOOK_SECRET! // from Step 1

// IMPORTANT: keep the raw body for HMAC. Standard JSON parsing breaks it.
app.post(
  '/agentcall/transcript',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const provided = req.header('X-AgentCall-Signature') || ''
    if (!provided.startsWith('sha256=')) return res.status(401).end()

    const expected =
      'sha256=' +
      crypto.createHmac('sha256', SECRET).update(req.body).digest('hex')

    const a = Buffer.from(expected)
    const b = Buffer.from(provided)
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(401).end()
    }

    const body = JSON.parse(req.body.toString('utf8'))
    // Envelope: { event, timestamp, data: { callId, transcript, summary, ... } }
    const { callId, transcript, summary } = body.data
    // feed callId / transcript / summary into your agent
    res.status(200).end()
  },
)
Cloudflare Worker / Web Crypto
export default {
  async fetch(req: Request, env: { SECRET: string }) {
    const body = await req.text()
    const provided = req.headers.get('X-AgentCall-Signature') || ''
    if (!provided.startsWith('sha256=')) return new Response('unauthorized', { status: 401 })

    const key = await crypto.subtle.importKey(
      'raw',
      new TextEncoder().encode(env.SECRET),
      { name: 'HMAC', hash: 'SHA-256' },
      false,
      ['sign'],
    )
    const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(body))
    const expected =
      'sha256=' +
      Array.from(new Uint8Array(sig))
        .map((b) => b.toString(16).padStart(2, '0'))
        .join('')

    let diff = expected.length === provided.length ? 0 : 1
    for (let i = 0; i < expected.length && i < provided.length; i++) {
      diff |= expected.charCodeAt(i) ^ provided.charCodeAt(i)
    }
    if (diff !== 0) return new Response('unauthorized', { status: 401 })

    const parsed = JSON.parse(body)
    // Envelope: { event, timestamp, data: { callId, transcript, summary, ... } }
    const { callId, transcript, summary } = parsed.data
    return new Response('ok')
  },
}
3

Process the transcript

Three patterns customers run, pick whichever fits:

Pattern A: store and analyze later

Write the payload to a database row keyed by callId. Cheapest. Good if your agent reads transcripts on its own schedule rather than reacting immediately.

Pattern B: forward to your agent in real time

POST the payload to your agent platform's ingest endpoint. The agent acts on the call before the caller is back at their desk. Best for the “agent answers, then executes” loop.

Pattern C: queue for a local agent to poll

If your agent runs locally (Docker on a VPS, on your laptop) and can't accept inbound HTTPS, host a tiny always-on bridge that queues transcripts. The agent polls on cron and drains the queue. The reference bridge below ships this pattern out of the box.

Reference Bridge for Local Agents (Pattern C)

If your agent runs locally and can't expose a public HTTPS URL, the same Cloudflare Worker that handles pre-call context also queues post-call transcripts (v0.2.0+). Two endpoints sit on the bridge alongside the pre-call handlers:

  • POST /agentcall/transcript receives the webhook from AgentCall, verifies the signature, and appends to a bounded queue in KV (max 100 entries, oldest dropped if exceeded). The bridge also dedups by callId on every write, so if AgentCall retries a delivery the same call never lands in the queue twice.
  • POST /hermes/pull-transcripts lets your local agent drain the queue. Returns all queued entries and atomically clears them. Protected by the same X-Hermes-Push-Key header.

Source is open at Kintupercy/agentcall-hermes-bridge. Already deployed your bridge for the pre-call context flow? You only need to redeploy with the new code; the KV namespace, secrets, and custom domain stay the same.

Have your local agent poll the bridge on cron (every 60 seconds)
# cron entry or background loop
curl -X POST https://hermes.your-domain.com/hermes/pull-transcripts \
  -H "X-Hermes-Push-Key: $HERMES_PUSH_KEY" \
  | jq '.transcripts[]' \
  | your-agent ingest-transcript

The pull endpoint is read-and-clear: anything you don't persist locally before the next pull is gone. Persist before you process.

Heads up for Python-based agents

Cloudflare blocks Python's default urllib User-Agent on this bridge with HTTP 403 (Error 1010, browser_signature_banned). If your local agent runs Python and pulls via urllib, every cron tick gets silently rejected at the Cloudflare edge before reaching the Worker. Fix: shell out to curl via subprocess.run(['curl', ...]), or set a non-default header like User-Agent: curl/8.0 on your urllib requests. Node, Go, Rust, and curl-based clients are unaffected. Check Cloudflare Security Events for Error 1010 if the queue depth on the bridge keeps climbing while your agent reports successful pulls.

Retries and Reliability

Retry policy: AgentCall retries failed deliveries with exponential backoff. A “failure” is any non-2xx response or a connection error.

Acknowledge fast: return 2xx as soon as you've verified the signature and persisted the payload. Heavy processing belongs in a background job, not inline. AgentCall's delivery worker times out long requests.

Idempotency: callId is stable across retries. Use it as the dedup key so a redelivered transcript doesn't double-fire your agent actions.

Rotate secrets: if the signing secret leaks, call webhooks.rotateSecret(webhookId) on the SDK or ask your agent to do it. The old secret is invalidated immediately; the new one is shown once.

Troubleshooting

Webhook fires but signature always invalid

Most often the request body was JSON-parsed before HMAC. Standard middleware re-serializes JSON and changes whitespace, breaking the signature. Use raw body for the signature check, then JSON-parse afterward. The Express example above uses express.raw for this reason.

Some calls don't produce a webhook

The webhook only fires for calls handled by inbound AI. Direct-to-voicemail or unanswered calls don't produce a transcript. The call.status webhook covers those.

Endpoint receives the payload but my agent already acted

Duplicate delivery. Always dedupe on callId: store the IDs you've processed and skip repeats. AgentCall retries until it gets a 2xx, so a slow first response can produce a duplicate.

Transcript is shorter than the call

Transcription captures turns the AI and the caller exchanged. Long stretches of silence, music on hold, or one side muted produce gaps. If you need the raw audio, subscribe to call.recording as well.

Reference

Ready to close the post-call loop?

Get an AgentCall API key, register a webhook for call.transcript, and start feeding outcomes back into your agent on every call.

Get API Key, Free