# 結果の受け取り

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

Source: https://voicast.jp/docs/results

通話が終わると、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`（[会話の設定](https://voicast.jp/docs/agents.md#通話の結果)）。通話中は `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`（[料金と請求](https://voicast.jp/docs/pricing.md)） |

一覧は `?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](https://webhookadmin.com/) が受け持ちます。

### 送り先の登録

`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` は記録と通知のためのもので、電話の転送の判断には使いません（[電話でつなぐ](https://voicast.jp/docs/phone.md#引き継ぎ)）。

### 応答と再送

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

## 署名の検証

Webhook には [Standard Webhooks](https://www.standardwebhooks.com/) の署名が付いています。[通話中の道具](https://voicast.jp/docs/tools.md)の呼び出しも同じ形です（鍵だけが違います）。

| 届くもの | 鍵 |
| --- | --- |
| 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](https://voicast.jp/docs/sdk.md) の `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` で、テナントごとの通話の数・秒数・分数・金額の目安を引けます。サービスの側で自社の顧客に請求するときの内訳に使えます（[料金と請求](https://voicast.jp/docs/pricing.md#利用の内訳)）。
