結果の受け取り
通話の
通話がoutcome)fields)
API で引く#
| 呼び出し | 内容 |
|---|---|
GET /v1/calls |
通話の |
GET /v1/calls/{id} |
通話 1 件 |
GET /v1/calls/by-provider/{providerCallId} |
Twilio のCallSid か、sess_…) |
GET /v1/calls/{id}/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
}| 項目 | 内容 |
|---|---|
outcome |
completed・answered・handoff・hangup・errornull |
reason |
AI が |
fields |
聞き取ったfields のkey ごと) |
summary・category |
通話のcall.summary_ready)null |
has_transcript |
文字起こしがtrue |
channel |
phoneweb |
output |
audiotext |
billed_yen |
このnull |
一覧は?tenant_id= で?limit=has_more がtrue のnext_cursor を?cursor= に
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_…" }文字起こし#
{
"call_id": "call_…",
"turns": [
{ "role": "assistant", "text": "お電話ありがとうございます。さくら歯科です。ご用件をお話しください。" },
{ "role": "user", "text": "来週の予約を変えたいんですが" }
]
}文字起こしは
Webhook#
通話の
送り先の登録#
POST /v1/webhooks/portalcreate_webhook_portal_link)whsec_…)
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 }自社のframe_originhttps://app.example.com)localeja・en、ja)
voicast の
届く形#
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 |
通話が |
通話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 |
残高が |
{ 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 |
月の |
請求書払いは{ 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 には
| 届く |
鍵 |
|---|---|
| Webhook | 送り先のwhsec_…) |
| 通話中の |
環境のGET /v1/tools/secret・MCP のget_tools_secret でwhsec_…) |
確かめ方:
- 署名する
文字列は {webhook-id}.{webhook-timestamp}.{本文}。本文は 届いた バイト列の まま 使う (JSON に 直してから 並べ直さない) - 鍵は
whsec_の後ろを base64 で 戻した バイト列 - HMAC-SHA256 の
結果を base64 にし、 webhook-signatureの空白区切りの v1,<署名>のどれか 1 つと 一致すれば 本物 (鍵を 作り直してから 24 時間は 2 つ 並びます) webhook-timestampが今から 5 分以上ずれていれば 断る (古い 要求の 使い回しを 防ぐ)
Node.js#
公式のnpm install standardwebhooks)verifyWebhook で
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#
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 は 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")
}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 で、