ページの一覧

ドキュメント

結果の受け取り

通話の結果・聞き取った項目・文字起こし・要約を API と Webhook で受け取る方法。Webhook のイベントと中身、署名の検証(Node.js・Python・PHP・Go)。

Markdown で見る

通話が終わると、voicast は結果(outcome)・聞き取った項目(fields)・文字起こし・要約を残します。API で引くことも、Webhook で知らせを受けることもできます。

API で引く#

呼び出し 内容
GET /v1/calls 通話の一覧(新しい順)
GET /v1/calls/{id} 通話 1 件
GET /v1/calls/by-provider/{providerCallId} Twilio の CallSid か、セッションの ID(sess_…)で引く
GET /v1/calls/{id}/transcript 文字起こし

通話(Call)#

json
{
  "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
}
項目 内容
outcome completed・answered・handoff・hangup・error(会話の設定)。通話中は null
reason AI がそう判断した理由(短い文)
fields 聞き取った項目(会話の設定の fields の key ごと)。電話番号の項目は数字だけ
summary・category 通話のあとに AI が作る要約と用件の分類。少し遅れて入ります(call.summary_ready)。作れなかったときは null
has_transcript 文字起こしがあれば true
channel phone(電話)か web(Web・アプリ)
output audio(声で返した)か text(文章だけを返した)
billed_yen この通話の利用料(税別の円、小数 3 桁まで)。チャージ式は残高から引いた額、請求書払いは秒数 × 契約の 1 分の値段 ÷ 60。通話中は null(料金と請求)

一覧は ?tenant_id= で絞り、?limit=(1〜100、既定 50)でページの件数を決めます。has_more が true のあいだ、応答の next_cursor を ?cursor= に渡して続きを読みます。

bash
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_…" }

文字起こし#

json
{
  "call_id": "call_…",
  "turns": [
    { "role": "assistant", "text": "お電話ありがとうございます。さくら歯科です。ご用件をお話しください。" },
    { "role": "user", "text": "来週の予約を変えたいんですが" }
  ]
}

文字起こしは通話が終わってから作られます。音声認識の結果なので、聞き間違いが混ざることがあります。

Webhook#

通話の始まり・引き継ぎ・終わり・文字起こし・要約などを、登録した URL に送ります。送信と再送・署名・配信の記録は、シャノンの Webhook Admin が受け持ちます。

送り先の登録#

POST /v1/webhooks/portal(MCP は create_webhook_portal_link)で、送り先を登録する画面のリンクを作ります(15 分有効)。画面では、送り先の URL・受け取るイベント・署名の鍵(whsec_…)の確認・試しの送信・配信の記録が扱えます。

bash
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 }

自社の管理画面に iframe で埋め込むときは、frame_origin(例 https://app.example.com)を渡します。画面の言葉は locale(ja・en、既定 ja)で選べます。送り先は環境ごとです。本番の環境とテストの環境で、それぞれ登録します。

voicast の管理画面からは、環境の設定の「Webhook の設定」で同じ画面を開けます。

届く形#

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", … } }

本文は { type, timestamp, data } です。data の中身はイベントごとに下のとおりです。

イベント#

type いつ data
call.started 通話が始まった { call_id, provider_call_id, from, started_at, tenant }
call.handoff_requested 引き継ぐ条件に当たった { call_id, provider_call_id, reason, fields, tenant }
call.ended 通話が終わった 通話(Call)の全体と tenant
call.transcript_ready 文字起こしができた { call_id, provider_call_id, turns, tenant }
call.summary_ready 要約ができた { call_id, provider_call_id, summary, category, fields, tenant }
connection.needs_reauth 連携が切れた(もう一度連携が要る) { connection_id, connector, label, status, tenant }(環境の連携は tenant: null)
billing.balance_low 残高がしきい値を下回った(オートチャージを使っていないとき) { organization_id, balance_yen, threshold_yen }
billing.balance_depleted 残高が 0 以下になった(新しい通話を断り始めた) { organization_id, balance_yen, call_id }
billing.recharge_failed オートチャージがカードで断られた { organization_id, invoice_id, amount_yen, attempt, max_attempts, auto_recharge_disabled, decline_code, balance_yen }
billing.payment_overdue 請求書の支払い期限が過ぎた { organization_id, invoice_id, amount_due, currency, due_at, grace_ends_at, hosted_invoice_url }
billing.payment_failed 請求書の支払いに失敗した { organization_id, invoice_id, amount_due, currency, attempt, next_payment_attempt, failed_at, grace_ends_at, hosted_invoice_url }
billing.cap_reached 月の上限に届いた(月に 1 回) 請求書払いは { organization_id, kind: 'usage', month, spending_cap_yen, estimated_yen }、チャージ式は { organization_id, kind: 'auto_recharge', month, spending_cap_yen, recharged_yen, amount_yen, balance_yen }

tenant は { id, external_id } です。external_id で自社の顧客を引けます。

  • call.* と connection.needs_reauth は、その通話・連携の環境の送り先に届きます。
  • billing.* は組織に 1 つの知らせなので、組織の本番の環境の送り先に届きます。
  • 1 つの通話では call.started → call.ended → call.transcript_ready → call.summary_ready の順に作りますが、届く順は保証しません。call.handoff_requested は記録と通知のためのもので、電話の転送の判断には使いません(電話でつなぐ)。

応答と再送#

  • 2xx を返すと成功です。それ以外の応答・時間切れ・接続の失敗は、間隔を空けて送り直します(既定で最大 8 回、約 28 時間)。
  • 同じ webhook-id が 2 回以上届くことがあります。処理済みの ID は捨てます。
  • 重い処理は先に 2xx を返してから行います。

署名の検証#

Webhook には Standard Webhooks の署名が付いています。通話中の道具の呼び出しも同じ形です(鍵だけが違います)。

届くもの 鍵
Webhook 送り先の署名の鍵(送り先を登録した画面で見る whsec_…)
通話中の道具 環境の道具の署名の鍵(管理画面の環境の設定・GET /v1/tools/secret・MCP の get_tools_secret で見る whsec_…)

確かめ方:

  1. 署名する文字列は {webhook-id}.{webhook-timestamp}.{本文}。本文は届いたバイト列のまま使う(JSON に直してから並べ直さない)
  2. 鍵は whsec_ の後ろを base64 で戻したバイト列
  3. HMAC-SHA256 の結果を base64 にし、webhook-signature の空白区切りの v1,<署名> のどれか 1 つと一致すれば本物(鍵を作り直してから 24 時間は 2 つ並びます)
  4. webhook-timestamp が今から 5 分以上ずれていれば断る(古い要求の使い回しを防ぐ)

Node.js#

公式のライブラリ(npm install standardwebhooks)か、voicast の SDK の verifyWebhook で確かめられます。

js
import express from 'express';
import { Webhook } from 'standardwebhooks';

const wh = new Webhook(process.env.VOICAST_WEBHOOK_SECRET); // whsec_…
const app = express();

// 署名は届いた本文そのもので確かめるので、JSON に変換する前の本文を受け取る
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#

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 "", 204

PHP#

php
<?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#

go
package voicast

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"errors"
	"net/http"
	"strconv"
	"strings"
	"time"
)

// Verify は Standard Webhooks の署名を確かめる。body は届いたバイト列のまま渡す
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")
}
go
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)
})

自社の顧客に請求する#

GET /v1/usage?group_by=tenant で、テナントごとの通話の数・秒数・分数・金額の目安を引けます。サービスの側で自社の顧客に請求するときの内訳に使えます(料金と請求)。