# 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.

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

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

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

| Field | Description |
| --- | --- |
| `outcome` | `completed`, `answered`, `handoff`, `hangup` or `error` ([Agents](https://voicast.jp/en/docs/agents.md#outcomes)). `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](https://voicast.jp/en/docs/pricing.md)) |

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.

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

### Transcript

```json
{
  "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](https://webhookadmin.com/), 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.

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

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.*` and `connection.needs_reauth` go 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_requested` is for records and notifications; do not use it to decide phone transfers ([Phone](https://voicast.jp/en/docs/phone.md#handoff)).

### 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-id` can 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](https://www.standardwebhooks.com/). Requests from [tools](https://voicast.jp/en/docs/tools.md) 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:

1. The signed content is `{webhook-id}.{webhook-timestamp}.{body}`, using the raw body bytes (do not re-serialize parsed JSON)
2. The key is the base64-decoded part after `whsec_`
3. Base64-encode the HMAC-SHA256 and compare it with each space-separated `v1,<signature>` in `webhook-signature`; any match is valid (two appear for 24 hours after a secret is rotated)
4. Reject requests whose `webhook-timestamp` is more than 5 minutes off (prevents replays)

### Node.js

Use the official library (`npm install standardwebhooks`) or `verifyWebhook` from the voicast [SDK](https://voicast.jp/en/docs/sdk.md).

```js
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

```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 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")
}
```

```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)
})
```

## 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](https://voicast.jp/en/docs/pricing.md#usage-breakdown)).
