Results
Getting the outcome, collected fields, transcript and summary of each call through the API and webhooks, with signature verification in Node.js, Python, PHP and Go.
When a call ends, voicast stores the outcome, the collected fields, a transcript and a summary. You can fetch them with the API or be notified by webhook.
Fetching with the API#
| Call | Returns |
|---|---|
GET /v1/calls |
Calls, newest first |
GET /v1/calls/{id} |
One call |
GET /v1/calls/by-provider/{providerCallId} |
A call by Twilio CallSid or session ID (sess_…) |
GET /v1/calls/{id}/transcript |
The transcript |
Call#
{
"id": "call_…",
"tenant_id": "ten_…",
"agent": { "id": "agt_…", "version": 3 },
"provider_call_id": "CA…",
"from": "+819012345678",
"started_at": 1790000000000,
"ended_at": 1790000084210,
"duration_ms": 84210,
"outcome": "completed",
"reason": "必須の項目がそろった",
"fields": { "name": "山田太郎", "phone": "09012345678", "purpose": "予約の変更" },
"summary": "予約の変更の依頼。来週火曜の午前を希望。",
"category": "予約の変更",
"has_transcript": true,
"channel": "phone",
"output": "audio",
"billed_yen": 8.5
}| Field | Description |
|---|---|
outcome |
completed, answered, handoff, hangup or error (Agents). null while the call is in progress |
reason |
Why the AI decided so (a short sentence) |
fields |
Collected values, keyed by the agent's field key. Phone fields are digits only |
summary, category |
A summary and a category the AI writes after the call. They arrive a little later (call.summary_ready) and are null if none could be made |
has_transcript |
true when a transcript exists |
channel |
phone or web (Web and apps) |
output |
audio (the AI spoke) or text (text replies only) |
billed_yen |
What this call costs you (yen before tax, up to 3 decimals): the amount taken from the balance (prepaid), or seconds × the contract per-minute price ÷ 60 (invoice). null while the call is in progress (Pricing and billing) |
Filter the list with ?tenant_id= and set the page size with ?limit= (1–100, default 50). While has_more is true, pass the response's next_cursor as ?cursor= to read on.
curl "https://api.voicast.jp/v1/calls?limit=100&cursor=1790000000000.call_…" \
-H "Authorization: Bearer $VOICAST_API_KEY"
# → { "data": [ … ], "has_more": true, "next_cursor": "1789999000000.call_…" }Transcript#
{
"call_id": "call_…",
"turns": [
{ "role": "assistant", "text": "お電話ありがとうございます。さくら歯科です。ご用件をお話しください。" },
{ "role": "user", "text": "来週の予約を変えたいんですが" }
]
}The transcript is created after the call ends. It comes from speech recognition, so it can contain mishearings.
Webhooks#
voicast sends call start, handoff, end, transcript, summary and other events to the URLs you register. Delivery, retries, signatures and delivery logs are handled by Webhook Admin, SHANNON's webhook platform.
Registering an endpoint#
POST /v1/webhooks/portal (MCP: create_webhook_portal_link) returns a link to a page where you register endpoints (valid for 15 minutes). On that page you can set the URL, choose events, see the signing secret (whsec_…), send test events and browse delivery logs.
curl -X POST https://api.voicast.jp/v1/webhooks/portal \
-H "Authorization: Bearer $VOICAST_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'
# → { "url": "https://app.webhookadmin.com/portal/…", "expires_at": 1790000900000 }To embed the page in your own dashboard in an iframe, pass frame_origin (e.g. https://app.example.com). Choose the page language with locale (ja or en, default ja). Endpoints are per environment; register them separately for live and test.
From the voicast dashboard, open the same page with Webhook settings in the environment settings.
Delivery format#
POST https://saas.example.com/webhooks/voicast
content-type: application/json
webhook-id: msg_2Zq8…
webhook-timestamp: 1790000090
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{ "type": "call.ended", "timestamp": "2026-10-03T01:32:05.000Z", "data": { "id": "call_…", "outcome": "completed", … } }The body is { type, timestamp, data }; data depends on the event.
Events#
type |
When | data |
|---|---|---|
call.started |
A call started | { call_id, provider_call_id, from, started_at, tenant } |
call.handoff_requested |
A handoff rule matched | { call_id, provider_call_id, reason, fields, tenant } |
call.ended |
A call ended | The whole Call plus tenant |
call.transcript_ready |
The transcript is ready | { call_id, provider_call_id, turns, tenant } |
call.summary_ready |
The summary is ready | { call_id, provider_call_id, summary, category, fields, tenant } |
connection.needs_reauth |
A connection needs to be connected again | { connection_id, connector, label, status, tenant } (tenant: null for environment connections) |
billing.balance_low |
The balance fell below the threshold (without auto-recharge) | { organization_id, balance_yen, threshold_yen } |
billing.balance_depleted |
The balance reached zero or below (new calls are refused) | { organization_id, balance_yen, call_id } |
billing.recharge_failed |
An auto-recharge was declined | { organization_id, invoice_id, amount_yen, attempt, max_attempts, auto_recharge_disabled, decline_code, balance_yen } |
billing.payment_overdue |
An invoice is past due | { organization_id, invoice_id, amount_due, currency, due_at, grace_ends_at, hosted_invoice_url } |
billing.payment_failed |
An invoice payment failed | { organization_id, invoice_id, amount_due, currency, attempt, next_payment_attempt, failed_at, grace_ends_at, hosted_invoice_url } |
billing.cap_reached |
A monthly cap was reached (once a month) | Invoice billing: { organization_id, kind: 'usage', month, spending_cap_yen, estimated_yen }. Prepaid: { organization_id, kind: 'auto_recharge', month, spending_cap_yen, recharged_yen, amount_yen, balance_yen } |
tenant is { id, external_id }; use external_id to find your customer.
call.*andconnection.needs_reauthgo to the endpoints of the environment the call or connection belongs to.billing.*concern the whole organization and go to the endpoints of the organization's live environments.- For one call, events are created in the order
call.started→call.ended→call.transcript_ready→call.summary_ready, but delivery order is not guaranteed.call.handoff_requestedis for records and notifications; do not use it to decide phone transfers (Phone).
Responses and retries#
- A 2xx response is a success. Any other response, a timeout or a connection failure is retried with backoff (by default up to 8 attempts over about 28 hours).
- The same
webhook-idcan arrive more than once. Drop IDs you have already processed. - Return 2xx first and do heavy work afterwards.
Verifying signatures#
Webhooks are signed with Standard Webhooks. Requests from tools use the same scheme with a different secret.
| What arrives | Secret |
|---|---|
| Webhooks | The endpoint's signing secret (whsec_…, shown on the endpoint page) |
| Tool calls | The environment's tools signing secret (whsec_…, from the environment settings in the dashboard, GET /v1/tools/secret or the MCP tool get_tools_secret) |
How to verify:
- The signed content is
{webhook-id}.{webhook-timestamp}.{body}, using the raw body bytes (do not re-serialize parsed JSON) - The key is the base64-decoded part after
whsec_ - Base64-encode the HMAC-SHA256 and compare it with each space-separated
v1,<signature>inwebhook-signature; any match is valid (two appear for 24 hours after a secret is rotated) - Reject requests whose
webhook-timestampis more than 5 minutes off (prevents replays)
Node.js#
Use the official library (npm install standardwebhooks) or verifyWebhook from the voicast SDK.
import express from 'express';
import { Webhook } from 'standardwebhooks';
const wh = new Webhook(process.env.VOICAST_WEBHOOK_SECRET); // whsec_…
const app = express();
// Verify against the raw body, before any JSON parsing
app.post('/webhooks/voicast', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = wh.verify(req.body, req.headers);
} catch {
return res.sendStatus(400);
}
if (event.type === 'call.ended') saveCall(event.data);
res.sendStatus(204);
});Python#
import os
from flask import Flask, request
from standardwebhooks import Webhook # pip install standardwebhooks
wh = Webhook(os.environ["VOICAST_WEBHOOK_SECRET"]) # whsec_…
app = Flask(__name__)
@app.post("/webhooks/voicast")
def webhooks():
try:
event = wh.verify(request.get_data(), dict(request.headers))
except Exception:
return "", 400
if event["type"] == "call.summary_ready":
save_summary(event["data"])
return "", 204PHP#
<?php
function voicast_verify(string $secret, array $headers, string $body, int $tolerance = 300): bool
{
$h = array_change_key_case($headers, CASE_LOWER);
$id = $h['webhook-id'] ?? '';
$ts = $h['webhook-timestamp'] ?? '';
if (!ctype_digit($ts) || abs(time() - (int) $ts) > $tolerance) {
return false;
}
$key = base64_decode(preg_replace('/^whsec_/', '', $secret));
$expected = base64_encode(hash_hmac('sha256', "{$id}.{$ts}.{$body}", $key, true));
foreach (explode(' ', $h['webhook-signature'] ?? '') as $part) {
[$version, $sig] = array_pad(explode(',', $part, 2), 2, '');
if ($version === 'v1' && hash_equals($expected, $sig)) {
return true;
}
}
return false;
}
$body = file_get_contents('php://input');
if (!voicast_verify(getenv('VOICAST_WEBHOOK_SECRET'), getallheaders(), $body)) {
http_response_code(400);
exit;
}
$event = json_decode($body, true);
http_response_code(204);Go#
package voicast
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"net/http"
"strconv"
"strings"
"time"
)
// Verify checks a Standard Webhooks signature. Pass the raw body bytes.
func Verify(secret string, h http.Header, body []byte) error {
id, ts := h.Get("webhook-id"), h.Get("webhook-timestamp")
t, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return errors.New("invalid timestamp")
}
if d := time.Since(time.Unix(t, 0)); d > 5*time.Minute || d < -5*time.Minute {
return errors.New("timestamp out of range")
}
key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
if err != nil {
return err
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(id + "." + ts + "."))
mac.Write(body)
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
for _, part := range strings.Split(h.Get("webhook-signature"), " ") {
if v, sig, ok := strings.Cut(part, ","); ok && v == "v1" && hmac.Equal([]byte(sig), []byte(expected)) {
return nil
}
}
return errors.New("signature mismatch")
}http.HandleFunc("/webhooks/voicast", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
if err := voicast.Verify(os.Getenv("VOICAST_WEBHOOK_SECRET"), r.Header, body); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
})Billing your own customers#
GET /v1/usage?group_by=tenant returns calls, seconds, minutes and an estimated amount per tenant, which you can use to bill your own customers (Pricing and billing).