Vonage Voice APIの録音はVonage側に30日しか残りません。通話を「あとで聞き直せる」「文字で検索できる」状態にするには、録音の取り方・保管先・文字起こしを最初に決めておく必要があります。この記事では、通話全体を録るNCCOから、Amazon S3への退避、Vonage内蔵の文字起こしとAmazon Transcribeの選び分け、S3イベントでの自動起動までを、公式ドキュメントとSDKの実測で組み立てます。
まとめ:音声通話APIの録音をS3で保管し文字起こしする要点
通話全体を録るなら、recordアクションにendOnSilence・endOnKey・timeOutを付けずにconnectの前へ置きます。どれか1つでも付けると同期録音になり、指定した無音時間・キー入力・最大録音時間の終了条件に従って録音が止まります。録音は完了後30日でVonageが削除するため、webhookで受けたrecording_urlを同じアプリケーションの鍵で署名したJWTで取得し、S3へ移します。Twilioのように、録音をS3へ直接書き込む設定はVonage Voice APIにはありません。
文字起こしは、Vonage内蔵のtranscriptionでもja-JPを指定できます。ただし既定はen-USで、Python SDK(vonage 4.9.0)のNCCOモデルでは指定が黙って捨てられます。チャンネル単位の話者分離やカスタム言語モデルが要るなら、S3イベントからAmazon Transcribeを起動します。保管では、128KB未満の録音がライフサイクルで遷移しない既定に注意してください。
音声通話APIで録音を扱う前提:機能範囲と30日の保持期限
音声通話APIとNCCOで制御できる範囲
音声通話API(Programmable Voice API)は、電話の発信・着信・IVRの分岐・録音・音声認識・転送を、HTTPリクエストとwebhookで制御する仕組みです。Vonage Voice APIでは通話中の動作をNCCO(Nexmo Call Control Object)というJSON配列で記述し、talkで読み上げ、connectで相手につなぎ、recordで録音し、inputでDTMFや音声認識を受け取ります。配列は上から順に評価されます。
VonageとTwilioのどちらを選ぶかは提供体制と料金の話になるため、VonageとTwilio比較|CPaaS選定で見る料金・提供体制・SMS制約で扱っています。ここでは、事業者を決めたあとに必ず設計が要る録音の保管と文字起こしに絞ります。
Vonage録音の30日間の保持期限
Vonageの録音ガイドは「After your recording is complete, it is stored by Vonage for 30 days before being automatically deleted」と明記しています。recording_urlは恒久的な置き場ではなく、31日目以降は取得できなくなる受け渡し用のURLです。
2026年10月1日に始まったカスタマーハラスメント対策の義務化で、この30日はさらに問題になります。厚生労働省のマニュアルは、カスハラが疑われる場合に録音や対応記録で第三者が検証できる証拠を集めるよう求めており、一律の自動削除では事案化した通話まで消えます(詳細はカスハラ対策義務化とは?2026年10月施行の10措置と録音・対応記録のシステム要件)。30日以内に自前ストレージへ移し、事案に紐づいた録音だけを長く残せる構成が要件です。
録音をS3へ直接書き込めるCPaaSとの違い
Twilioには、録音をS3へ直接書き込む設定(Voice Recording External Storage)があります。Twilioの録音設定ドキュメントによると、有効にしたあとの新規録音はS3へアップロードされ、Twilio側にはコピーが残りません。一方で、認証情報を設定していないサブアカウントで録音が発生すると、その録音はError 53611として黙って捨てられます。
Vonageでも、Video API(旧OpenTok)のアーカイブにはS3バケットを登録する設定があります。しかしCPaaSのVoice APIの録音ガイドやNCCO・Webhookの各リファレンスに同等の項目は無く、用意されているのはwebhookで届くダウンロードURLだけです。退避処理は自分で書きます。
recordアクションで通話全体を録音するNCCO
同期録音と非同期録音の違い
recordには2つの動き方があります。endOnSilence・endOnKey・timeOutのどれかを指定すると同期録音になり、無音タイマー・キー入力・最大長のいずれかが成立した時点で録音が終わります。留守番電話のように、1人の発話だけを録る用途向けです。
3つとも指定しなければ非同期録音になります。録音を続けたまま次のアクションへ進み、通話が終わった時点で録音を閉じてイベントを送ります。2者の通話を丸ごと残すなら、こちらを使います。endOnSilence: 5を付けたNCCOを通話録音に使うと、5秒の沈黙で録音が止まり、その後の会話が残りません。7200秒の上限も同期録音のtimeOutの値なので、非同期録音には当てはまりません。
[
{
"action": "record",
"split": "conversation",
"channels": 2,
"format": "mp3",
"beepStart": true,
"eventUrl": ["https://example.com/webhooks/recording"]
},
{
"action": "connect",
"from": "81501234XXXX",
"endpoint": [{ "type": "phone", "number": "81901234XXXX" }]
}
]
eventUrlは値が1つでも配列で渡します。NCCOリファレンスにあるrecordの主要パラメータは次のとおりです。
| パラメータ | 指定できる値 | 注記 |
|---|---|---|
| format | mp3 / wav / ogg | 既定mp3(3ch以上はwav) |
| split | conversation | ステレオ録音を有効化 |
| channels | 1〜32 | split必須 |
| endOnSilence | 3〜10秒 | 指定すると同期録音 |
| timeOut | 3〜7200秒 | 指定すると同期録音 |
| transcription | オブジェクト | 内蔵の文字起こし |
split=conversationと録音ファイルの大きさ
splitにconversationを指定すると、最初の発信者が話した音声が左チャンネル、最初の発信者が聞いた音声が右チャンネルに入ります。左右がそのまま話者に対応するため、後段で音響的に話者を推定する必要がありません。channelsは32まで指定できますが、後述するAmazon Transcribeのチャンネル識別は2チャンネルまでです。1対1の通話を解析するなら2を超えないようにします(3チャンネル以上では既定のformatもwavに変わります)。
録音ガイドによれば、MP3は16bit・16kHz・32Kbpsの固定ビットレートで、既定はモノラルです。32Kbpsは1秒あたり4,000バイトなので、モノラルでは約33秒未満の録音が128KB(131,072バイト)を下回ります。この境界は後述するS3ライフサイクルの既定に関わります。ステレオ時のビットレートは明記されていないため、実際の大きさはwebhookのsizeで確認してください。
録音をS3へ退避する実装
webhookのフィールドとオブジェクトキーの決め方
録音が終わると、VonageはeventUrlへrecording_url・recording_uuid・conversation_uuid・size・start_time・end_time・timestampを送ります(Webhookリファレンス)。recording_urlはhttps://api.nexmo.com/v1/files/{id}の形式です。
S3のオブジェクトキーにはrecording_uuidを使います。webhookが再送されても同じキーに上書きされるだけなので、重複ファイルが増えません。conversation_uuidをプレフィックスにすると、CRMの通話履歴から該当ファイルを引けます。
webhook受信時の署名検証とSQSによる退避処理の分離
VonageのEvent URLは接続タイムアウトが1秒、ソケットタイムアウトが10秒で、2xx以外を返すと1回だけ再送されます。ダウンロードとアップロードをwebhookの中で済ませると、ファイルが大きいときに10秒を超えます。そのため、受信側は署名の検証とキューへの投入だけを行い、すぐに204を返します。
署名付きコールバックを有効にすると、AuthorizationヘッダーにHS256で署名されたJWTが載り、ダッシュボードのsignature secretで検証できます。検証を省くと、第三者がPOSTを送るだけで処理が走ります。
import json
import os
import boto3
import jwt
from flask import Flask, request
app = Flask(__name__)
sqs = boto3.client("sqs", region_name="ap-northeast-1")
QUEUE_URL = os.environ["RECORDING_QUEUE_URL"]
SIGNATURE_SECRET = os.environ["VONAGE_SIGNATURE_SECRET"]
@app.route("/webhooks/recording", methods=["POST"])
def on_recording():
token = request.headers.get("Authorization", "").removeprefix("Bearer ")
try:
jwt.decode(token, SIGNATURE_SECRET, algorithms=["HS256"])
except jwt.PyJWTError:
return "", 401
sqs.send_message(QueueUrl=QUEUE_URL, MessageBody=json.dumps(request.get_json(force=True)))
return "", 204
JWTでのダウンロードとS3へのPUT
録音ガイドは「You will need to authenticate with a JWT signed by the same application key that created the recording」と書いています。別アプリケーションの鍵で署名したJWTでは取得できません。キューを読むワーカー(SQSトリガーのLambda)では、SDKのdownload_recordingで取得してS3へ上げます。vonage-voice 1.5.1の実装は、URL文字列全体にvonage.comもnexmo.comも含まないURLをVoiceErrorで拒否します。ただしこれは文字列の部分一致なので、https://example.invalid/nexmo.com/のようなURLも通ります。上のコードでは、取得前にスキームがHTTPSであることとホスト名を自分で確かめています。
import json
import os
from urllib.parse import urlparse
import boto3
from vonage import Auth, Vonage
s3 = boto3.client("s3", region_name="ap-northeast-1")
vonage_client = Vonage(
Auth(
application_id=os.environ["VONAGE_APPLICATION_ID"],
private_key=os.environ["VONAGE_PRIVATE_KEY_PATH"],
)
)
BUCKET = os.environ["RECORDING_BUCKET"]
def handler(event, context):
for message in event["Records"]:
rec = json.loads(message["body"])
url = urlparse(rec["recording_url"])
if url.scheme != "https" or not url.hostname.endswith((".nexmo.com", ".vonage.com")):
raise ValueError("unexpected recording_url")
key = "voice/{}/{}.mp3".format(rec["conversation_uuid"], rec["recording_uuid"])
path = "/tmp/{}.mp3".format(rec["recording_uuid"])
vonage_client.voice.download_recording(rec["recording_url"], path)
s3.upload_file(path, BUCKET, key, ExtraArgs={"Metadata": {"start-time": rec["start_time"]}})
os.remove(path)
オブジェクトキーがrecording_uuidで決まるので、再処理でも同じキーへ保存されます。ただし、再PUTによるイベント通知や、バージョニング有効時の新しいバージョンは発生するため、後続処理も重複に対応させます。S3は2023年1月5日以降、新規オブジェクトにSSE-S3を自動適用しているため、暗号化の指定は鍵を自社管理する場合(SSE-KMS)だけで足ります。
録音イベントの欠落検知と30日以内の回収
webhookの再送は1回だけなので、受信側が2回続けて落ちると録音のURLが届きません。通話の完了イベントと録音イベントをconversation_uuidで突き合わせ、録音イベントが無い通話を日次で洗い出します。回収経路の候補はMedia APIで、GET https://api.nexmo.com/v3/media/がstart_time・end_timeで絞った一覧を、/v3/media/:idがファイル本体を返します。ただし通話録音がこの一覧に含まれるとはリファレンスに明記されていないため、当てにする前に自分のアカウントで確かめてください。
文字起こしの選択:Vonage内蔵かAmazon Transcribeか
Vonage内蔵transcriptionの日本語指定とSDKの制約
recordにtranscriptionオブジェクトを付けると、Vonageが録音を文字起こしします。NCCOリファレンスによると、指定できる言語は音声認識(ASR)の対応言語と同じで、日本語のja-JPも含まれます。ただしlanguageを省くと既定のen-USで処理されます。文字起こしは2時間が上限の有料機能で、単価はVoice API料金ページの「Programmable Features」に載っています。各発話の感情を-1〜1で返すsentimentAnalysisはDeveloper Previewの扱いです。
{
"action": "record",
"split": "conversation",
"channels": 2,
"eventUrl": ["https://example.com/webhooks/recording"],
"transcription": {
"language": "ja-JP",
"eventUrl": ["https://example.com/webhooks/transcription"]
}
}
完了すると、statusがtranscribedでtranscription_urlを含むwebhookが届きます。このURLも録音と同じ鍵で署名したJWTで取得します。返ってくるJSONはchannels配列の中に、文ごとのsentence・timestamp・durationと、単語ごとのconfidenceを持つ形式です。
Python SDKでNCCOを組み立てる場合は注意が必要です。vonage 4.9.0(2026年9月16日公開)に含まれるvonage-voice 1.5.1のRecordモデルにはtranscriptionフィールドがありません。Record(..., transcription={"language": "ja-JP"})と書いてもエラーにならず、model_dump()の出力からtranscriptionが消えます(手元のvenvで実測)。内蔵の文字起こしを使うなら、NCCOは上のように辞書で直接書いてください。
Amazon Transcribeを選ぶ条件
手軽さだけなら内蔵で足ります。次のどれかに当てはまる場合は、S3へ退避した録音をAmazon Transcribeへ渡す構成を選びます。
- 話者をチャンネルで確実に分けたい。Transcribeのチャンネル識別は、左右の音声を
ch_0とch_1のラベル付きで返す。 - 業界用語や商品名の認識を上げたい。
ja-JPはカスタム言語モデル(CLM)に対応している。 - 文字起こし結果もAWS内で保管・権限管理したい。出力先を自社のS3バケットに指定できる。
- 2時間を超える通話がある。内蔵の文字起こしは2時間が上限。
日本語で使えない機能もあります。Transcribeの対応言語表では、ja-JPの個人情報の自動マスキング(Redaction)は「no」、Call Analyticsは「post-call」のみです。Call Analyticsの通話要約は英語の各方言、issue detectionはen-AU・en-GB・en-USに限られます。Transcribe全体の機能と使い方はAmazon Transcribeとは?料金・使い方・日本語対応と対応形式を解説【2026年版】、Google・OpenAIなど他サービスとの精度と料金の比較は文字起こしAPIの比較と選び方|主要7サービスの料金・日本語対応・使い方【2026年最新】にまとめています。
S3イベントによるAmazon Transcribeの自動起動
S3イベント通知からジョブを開始するLambda
録音用バケットのvoice/プレフィックスに、サフィックスを.mp3に絞ったイベント通知(s3:ObjectCreated:*)を設定し、次のLambdaを起動します。押さえる点は3つです。イベントに入るキーはURLエンコードされているので、unquote_plusで戻します(S3のイベント構造)。ジョブ名に使えるのは英数字と.・_・-だけで、上限は200文字です。同名のジョブはConflictExceptionになるため、この例では同名ジョブの再作成を省略します。本番では既存ジョブの入力と状態を確認し、FAILEDなら原因を解消して再実行する経路を設けます。
import os
import re
import urllib.parse
import boto3
transcribe = boto3.client("transcribe", region_name="ap-northeast-1")
OUTPUT_BUCKET = os.environ["TRANSCRIPT_BUCKET"]
def handler(event, context):
for record in event["Records"]:
bucket = record["s3"]["bucket"]["name"]
key = urllib.parse.unquote_plus(record["s3"]["object"]["key"])
stem, ext = os.path.splitext(os.path.basename(key))
job_name = re.sub(r"[^0-9A-Za-z._-]", "-", stem)[:200]
try:
transcribe.start_transcription_job(
TranscriptionJobName=job_name,
LanguageCode="ja-JP",
MediaFormat=ext.lstrip(".").lower(),
Media={"MediaFileUri": "s3://{}/{}".format(bucket, key)},
OutputBucketName=OUTPUT_BUCKET,
OutputKey="transcripts/{}.json".format(job_name),
Settings={"ChannelIdentification": True},
)
except transcribe.exceptions.ConflictException:
pass
OutputBucketNameは省略しないでください。Transcribeの入出力の説明によると、省略時の出力はサービス管理のバケットに置かれ、ダウンロード用の一時URIは15分で切れ、結果はジョブの期限(90日)で削除されます。Lambdaの実行ロールには、transcribe:StartTranscriptionJobのほか、入力バケットへのs3:GetObjectと出力バケットへのs3:PutObjectを付けます。出力先を録音用バケットのvoice/以外にしておくと、出力の書き込みでこのLambdaが再び起動することもありません。
チャンネル識別結果から発話ログへの変換
チャンネル識別を有効にすると、出力JSONにchannel_labelsが加わり、チャンネルごとに単語単位のitemsが並びます。公式ドキュメントは「Amazon Transcribe doesn’t currently support audio with more than two channels」とも書いています。3チャンネル以上の録音は、そのままでは入力できません。例えばモノラルへダウンミックスしたうえで、ShowSpeakerLabelsとMaxSpeakerLabels(2〜30)による話者分離に切り替えます。
単語単位のままではCRMに貼れないので、間隔が1秒以内の単語をつないで発話にまとめ、開始時刻順に並べ直します。日本語は単語の間に空白を入れずに連結します。句読点の項目には時刻が無いため、直前の発話に足します。
def to_utterances(transcript, gap=1.0):
utterances = []
for channel in transcript["results"]["channel_labels"]["channels"]:
current = None
for item in channel["items"]:
word = item["alternatives"][0]["content"]
if item["type"] == "punctuation":
if current:
current["text"] += word
continue
start, end = float(item["start_time"]), float(item["end_time"])
if current and start - current["end"] <= gap:
current["text"] += word
current["end"] = end
else:
current = {"channel": channel["channel_label"], "start": start, "end": end, "text": word}
utterances.append(current)
return sorted(utterances, key=lambda u: u["start"])
Vonageのsplit: conversationでは左チャンネルが最初の発信者の音声なので、ch_0が最初の発信者、ch_1がもう一方になります。顧客からの着信ならch_0は顧客です。CRMに保存するときは、通話の向きを見てラベルを「顧客」「担当者」に置き換えてください。
S3側の保管設計で踏みやすい2つの罠
128KB未満の録音に適用されるS3の既定遷移制限
録音は日が経つほど参照されなくなるため、安価なストレージクラスへ落とす設計が自然です。S3のライフサイクルの説明は「Starting September 2024, the default behavior prevents objects smaller than 128 KB from being transitioned to any storage class」としています。前述のとおり、モノラルのMP3では約33秒未満の録音がこの128KBを下回るため、短い問い合わせや不在着信の録音はS3 Standardに残り続けます。
import boto3
s3 = boto3.client("s3", region_name="ap-northeast-1")
# このAPIは既存のライフサイクル設定全体を置き換えます。
# 既存バケットでは、保持するルールも含めて送信してください。
s3.put_bucket_lifecycle_configuration(
Bucket="my-voice-recordings",
LifecycleConfiguration={
"Rules": [
{
"ID": "voice-recordings-archive",
"Status": "Enabled",
"Filter": {"And": {"Prefix": "voice/", "ObjectSizeGreaterThan": 131072}},
"Transitions": [
{"Days": 30, "StorageClass": "STANDARD_IA"},
{"Days": 180, "StorageClass": "GLACIER_IR"},
],
},
{
"ID": "voice-recordings-expiration",
"Status": "Enabled",
"Filter": {"Prefix": "voice/"},
"Expiration": {"Days": 1095},
}
]
},
)
閾値を128KBより下げるときは総額で比べてください。Standard-IA・One Zone-IA・Glacier Instant Retrievalは最小課金サイズが128KBで、「If an object is less than 128 KB, Amazon S3 charges you for 128 KB」と明記されています。これにオブジェクトごとの遷移リクエスト料金が加わるため、小さい録音ほどStandardに置いたままより高くつく場合があります。既定の閾値そのものを変えるなら、TransitionDefaultMinimumObjectSizeで指定します。
上のExpiration(1,095日=3年)はvoice/にだけ効きます。カスハラ事案や紛争に紐づいた録音はcopy_objectでcases/へ複製すれば、事案の終結まで期限から外せます。なお2024年9月より前のライフサイクル設定も、ルールを作成・編集・削除した時点で新しい既定に切り替わります。
Glacier Flexible Retrieval・Deep Archiveの40KB加算と最低保管日数
Glacier Flexible RetrievalとDeep Archiveへ移したオブジェクトには、8KBがS3 Standardの料金で、32KBが遷移先の料金で加算されます。Glacier Instant Retrievalにはこの加算はありません。数十KBの録音を大量に送ると、加算分が本体と同じ桁になります。
最低保管日数はStandard-IAが30日、Glacier Instant RetrievalとFlexible Retrievalが90日、Deep Archiveが180日です。これより早く削除・遷移すると残りの日数分が請求されるため、多段の遷移では後段を「前段の遷移日+前段の最低保管日数」以降に置きます(30日目にGlacier Instant Retrievalなら次は120日目以降)。各クラスの取り出し時間と料金はAmazon S3 Glacierとは?3つのアーカイブストレージクラスと取り出し時間・料金・採用判断を実装者目線で解説で比較しています。
Amazon Transcribeの東京リージョン単価と月額試算
AWS Price List APIのap-northeast-1向け価格ファイル(publicationDate 2026年9月11日)から、Amazon Transcribeの秒単価を抜き出しました。
| 種別 | 秒単価(USD) | 分換算(USD) |
|---|---|---|
| 標準バッチ | 0.0001 | 0.006 |
| ストリーミング | 0.0001667 | 0.010 |
| Call Analytics(〜25万分) | 0.0005 | 0.030 |
| Call Analytics(25万〜100万分) | 0.00031 | 0.0186 |
| Call Analytics(100万分〜) | 0.00023 | 0.0138 |
| カスタム言語モデル追加料金(〜25万分) | 0.0001 | 0.006 |
1日1,000通話、平均5分、月30日稼働の窓口なら月150,000分です。標準バッチなら月900ドル、Call Analyticsのポストコール分析まで通すと月4,500ドルで、5倍の差が出ます。日本語では通話要約とissue detectionが使えないため、要約や応対評価が目的なら、標準バッチの結果をLLMへ渡す構成も候補です。費用はLLMのモデル、入出力トークン数、再処理回数を含めて比較します。内蔵の文字起こしとTranscribeを両方有効にすると同じ通話に2回課金されるため、どちらか一方に寄せてください。
録音・文字起こしの結果をCRMや社内システムへ渡す連携部分の設計は、API開発・システム連携の支援範囲です。
録音後のバッチ文字起こしが向かない要件
録音してS3に置き、あとから文字起こしする構成は、次の要件には向きません。要件定義の段階で当てはまるものがあれば、別の構成を先に検討してください。
- 通話中に判断したい場合。録音のwebhookは通話が終わってからしか届かないため、オペレーター支援やリアルタイムのエスカレーションには間に合わない。WebSocketで音声ストリームを受ける構成を最初から選ぶ。
- 3者以上の通話を話者ごとに正確に分けたい場合。チャンネル分離が効くのは2チャンネルまでなので、音響的な話者分離の精度が要件を満たすかを先に検証する。
- 個人情報の自動マスキングを前提にする場合。
ja-JPはRedactionの対象外。決済情報を口頭で扱う業務では、該当区間の録音を止めて番号入力をDTMFに寄せるほうが確実。
よくある質問
音声通話APIでは何ができますか?
発信・着信・IVRの分岐・録音・音声認識・転送を、HTTPリクエストとwebhookで制御できます。Vonage Voice APIでは通話中の動作をNCCOというJSON配列で書き、上から順に実行されます。recordをconnectの前に置けば、通話の開始から終了までを録音できます。
Vonage Voice APIの録音はいつまで残りますか?
完了後30日間Vonageに保存され、そのあと自動で削除されます。31日目以降はrecording_urlから取得できないため、長期保管が要件なら30日以内にS3などへ移す処理が必要です。S3へ移したあとの保持期間は、ライフサイクル設定のExpirationで決まります。
Vonage Voice APIで日本語の文字起こしはできますか?
できます。recordのtranscriptionでlanguageにja-JPを指定します。省略すると既定のen-USで処理されます。上限は2時間で、有料機能です。Python SDKのNCCOモデル(vonage-voice 1.5.1)はtranscriptionを受け付けずに捨てるため、NCCOは辞書で書きます。
Vonageの通話録音をS3へ直接保存する設定はありますか?
CPaaSのVoice APIにはありません。webhookで受けたrecording_urlから自分でダウンロードし、S3へアップロードします。Video APIのアーカイブにはS3への自動エクスポートがあり、TwilioにもVoice Recording External Storageがあります。
日本語の通話録音で個人情報の自動マスキングは使えますか?
Amazon Transcribeの対応言語表では、ja-JPのRedactionは非対応です。対応しているのは英語・フランス語・ドイツ語・イタリア語・ポルトガル語・スペイン語の一部の方言です。日本語では、文字起こしの結果に対して自前でマスキングを行います。