{"templateId":"markdown","sharedDataIds":{"sidebar":"sidebar-docs/voice-2.0/sidebars.yaml"},"props":{"metadata":{"markdoc":{"tagList":[]},"type":"markdown"},"seo":{"title":"Record Calls & Transcribe Audio","siteUrl":"https://developers.sinch.com","llmstxt":{"title":"Sinch Developer Documentation","description":"LLMs.txt containing a map of all the documentation files for Sinch.","sections":[{"title":"Numbers API","description":"The Numbers API enables you to search for, view, and activate numbers. It's considered a precursor to other APIs in the Sinch product family. The numbers API can be used in tandem with any of our APIs that perform messaging or calling.","includeFiles":["docs/numbers/**/*.md","docs/numbers/**/*.yaml"],"excludeFiles":["docs/numbers/index.md"]},{"title":"Conversation API","description":"Send and receive messages globally on many popular channels with ease and confidence when using Sinch's Conversation API. Conversation API is the preferred API for sending mobile messages on SMS and other social channels with Sinch. It is a simple API with unified error messages, consistent request payloads, and common webhook payloads that are channel-agnostic.","includeFiles":["docs/conversation/**/*.md","docs/conversation/**/*.yaml"],"excludeFiles":["docs/conversation/index.md"]},{"title":"Voice API","description":"The Voice API works as a big telephony switch. The Voice API handles incoming phone calls (also known as incoming call “legs”), sets up outgoing phone calls (or outgoing call “legs”), and bridges the two. The incoming call leg may come in over a data connection (from a smartphone or web application using the Sinch SDKs) or through a local phone number (from the PSTN network). Similarly, the outgoing call leg can be over data (to another smartphone or web application using the Sinch SDKs) or the PSTN network.","includeFiles":["docs/voice/**/*.md","docs/voice/**/*.yaml"],"excludeFiles":["docs/voice/index.md"]},{"title":"Voice API v2","description":"The Voice API works as a big telephony switch. The Voice API handles incoming phone calls (also known as incoming call “legs”), sets up outgoing phone calls (or outgoing call “legs”), and bridges the two. The incoming call leg may come in over a data connection (from a smartphone or web application using the Sinch SDKs) or through a local phone number (from the PSTN network). Similarly, the outgoing call leg can be over data (to another smartphone or web application using the Sinch SDKs) or the PSTN network.","includeFiles":["docs/voice-2.0/**/*.md","docs/voice-2.0/**/*.yaml"],"excludeFiles":["docs/voice-2.0/index.md"]},{"title":"Verification API","description":"The Verification API is a platform for phone number verification. It consists of the API and different software development kits (the Sinch SDKs) that you integrate with your smartphone or web application and cloud based back-end services. Together they enable SMS, Flashcall, Phone Call and Data verification in your application.","includeFiles":["docs/verification/**/*.md","docs/verification/**/*.yaml"],"excludeFiles":["docs/verification/index.md"]},{"title":"Provisioning API","description":"Provisioning API allows you to programmatically set up your senders, accounts and templates on your favorite messaging platforms on the Conversation API. For now, you can create your first WhatsApp channel through Meta's Embedded sign up, you can configure your first SMS App and configure your webhooks. As development continues, we will be adding the most commonly used channels.","includeFiles":["docs/provisioning-api/**/*.md","docs/provisioning-api/**/*.json"],"excludeFiles":["docs/provisioning-api/index.md"]},{"title":"Elastic SIP Trunking API","description":"With Elastic SIP Trunking you can create and manage your SIP trunks and phone numbers programmatically.","includeFiles":["docs/est/**/*.md","docs/est/**/*.yaml"],"excludeFiles":["docs/est/index.md"]},{"title":"Fax API","description":"Send and receive HIPAA compliant faxes on our modern fax platform using our developer-friendly API.","includeFiles":["docs/fax/**/*.md","docs/fax/**/*.yaml"],"excludeFiles":["docs/fax/index.md"]},{"title":"In-app Voice and Video SDK","description":"The In-app Voice and Video SDK enables you to add voice and video calling capabilities directly into your mobile or web application using the Sinch SDKs.","includeFiles":["docs/in-app-calling/**/*.md"],"excludeFiles":["docs/in-app-calling/index.md"]},{"title":"Number Lookup API","description":"The Number Lookup API is designed to provide in-depth information about phone numbers, helping enterprises enhance their communication strategies and prevent fraud. By identifying the type of phone line (for example, mobile, landline, VoIP) and the associated carrier, the API allows businesses to optimize routing, reduce unnecessary costs, and improve customer engagement.","includeFiles":["docs/number-lookup-api-v2/**/*.md","docs/number-lookup-api-v2/**/*.yaml"],"excludeFiles":["docs/number-lookup-api-v2/index.md"]},{"title":"Functions","description":"Serverless compute for voice and messaging. Deploy your code and Sinch routes live calls and messages to it — no infrastructure to run.","includeFiles":["docs/functions/functions/**/*.md","docs/functions/concepts/**/*.md","docs/functions/reference/**/*.md"],"excludeFiles":["docs/functions/functions/index.md"]},{"title":"CLI","description":"One command line for every Sinch API and the full Functions lifecycle — scaffold, run locally, deploy, and manage.","includeFiles":["docs/functions/cli/**/*.md"],"excludeFiles":["docs/functions/cli/index.md"]}]}},"dynamicMarkdocComponents":[],"compilationErrors":[],"ast":{"$$mdtype":"Tag","name":"article","attributes":{},"children":[{"$$mdtype":"Tag","name":"Heading","attributes":{"level":1,"id":"record-calls--transcribe-audio","__idx":0},"children":["Record Calls & Transcribe Audio"]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"overview","__idx":1},"children":["Overview"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["The Voice API v2 can record a call and upload the audio file directly to your own cloud storage bucket (AWS S3, Google Cloud Storage, or Azure Blob Storage) as soon as the recording stops. Optionally it can also transcribe the recording to text and deliver a transcript file alongside the audio. Recording is controlled by the ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["startRecording"]}," and ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["stopRecording"]}," SVAML commands, which you can include inline in an outbound call payload or return from a webhook for inbound calls."]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["You specify the destination provider, the storage credentials, the recording format (",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["MP3"]}," or ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["WAV"]},"), the recording direction (",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["COMBINED"]}," / ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["INBOUND"]}," / ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["OUTBOUND"]},"), and whether transcription is enabled."]},{"$$mdtype":"Tag","name":"blockquote","attributes":{},"children":[{"$$mdtype":"Tag","name":"p","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Heads-up: recording needs storage configured before anything works."]}," Unlike most tutorials in this series, you cannot see a useful result until a real bucket and credentials exist, because Sinch uploads straight to your storage. Budget 10 to 15 minutes for the AWS setup in ",{"$$mdtype":"Tag","name":"MarkdownLink","attributes":{"href":"#setup"},"children":["Setup"]}," before you run anything. Once that is done, the first-success path below is a single script."]}]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"real-life-examples","__idx":2},"children":["Real-life examples"]},{"$$mdtype":"Tag","name":"ul","attributes":{},"children":[{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Compliance and quality assurance"]},": Record all customer service calls and store them in S3 for regulatory review."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Sales coaching"]},": Record sales calls, transcribe them, and feed the transcripts into an AI coaching tool."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Dispute resolution"]},": Maintain an auditable record of conversations for insurance claims or legal disputes."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Inbound-only recording"]},": Capture only the caller's audio (consent reasons) by setting ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["recordingType: INBOUND"]},"."]}]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"setup","__idx":3},"children":["Setup"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["This tutorial reads its configuration from environment variables. Export them in the shell session you will run the examples from. Exports last for the current shell only, so re-export them (or add them to your shell profile) if you open a new terminal."]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":3,"id":"1-sinch-credentials","__idx":4},"children":["1. Sinch credentials"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["You need a ",{"$$mdtype":"Tag","name":"MarkdownLink","attributes":{"href":"https://dashboard.sinch.com"},"children":["Sinch account"]},", a Voice-enabled virtual number, and an API key. Export them:"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"bash","header":{"controls":{"copy":{}}},"source":"export PROJECT_ID=your-project-id\nexport KEY_ID=your-key-id\nexport KEY_SECRET=your-key-secret\nexport SINCH_NUMBER=+1XXXXXXXXXX          # your Sinch virtual number, E.164\nexport DESTINATION_NUMBER=+1YYYYYYYYYY    # the number to call (for the outbound trigger)\n","lang":"bash"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Auth to the Voice API is ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["HTTP Basic"]}," with ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["KEY_ID:KEY_SECRET"]},"."]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":3,"id":"2-storage-bucket--credentials-the-part-that-takes-the-most-time","__idx":5},"children":["2. Storage bucket + credentials (the part that takes the most time)"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Recording uploads go to ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["your"]}," bucket, so you must create one and a write-scoped credential. AWS S3 is the recommended/headline path for this tutorial. It is the spec default (",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["destination: AWS"]},") and has the simplest credential format. GCS and Azure are covered under ",{"$$mdtype":"Tag","name":"MarkdownLink","attributes":{"href":"#other-storage-providers"},"children":["Other storage providers"]},"."]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["AWS S3 (recommended):"]}]},{"$$mdtype":"Tag","name":"ol","attributes":{},"children":[{"$$mdtype":"Tag","name":"li","attributes":{},"children":["Create an S3 bucket, e.g. ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["my-voice-recordings"]},", in a region you control (e.g. ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["eu-central-1"]},")."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":["Create an IAM user with programmatic access and a policy granting at least ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["s3:PutObject"]}," on ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["arn:aws:s3:::my-voice-recordings/*"]},". Grant the minimum, and do not attach full S3 access."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":["Copy the ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Access key ID"]}," and ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Secret access key"]},"."]}]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Then export both storage variables:"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"bash","header":{"controls":{"copy":{}}},"source":"export STORAGE_DESTINATION_URL=s3://my-voice-recordings/recordings/\nexport STORAGE_CREDENTIALS=AKIAIOSFODNN7EXAMPLE:wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY:eu-central-1\n","lang":"bash"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["The credential format for AWS is exactly ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["ACCESS_KEY:SECRET_KEY:REGION"]}]},", three colon-separated fields. This is the same format the spec uses in its ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["recordingOptions"]}," example (",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["accessKeyId:secretAccessKey:eu-central-1"]},"). The examples in this tutorial ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["infer the provider from the URL scheme"]},": ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["s3://"]}," maps to ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["AWS"]},", ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["gs://"]}," maps to ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["GCP"]},", anything else maps to ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["AZURE"]},". So set ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["STORAGE_DESTINATION_URL"]}," to match your provider and the ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["destination"]}," field is filled in for you."]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":3,"id":"3-inbound-only-a-public-callback-url","__idx":6},"children":["3. (Inbound only) a public callback URL"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["The outbound trigger does ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["not"]}," need a public URL. The webhook servers do, because Sinch must reach them. Expose your local server with ngrok and export:"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"bash","header":{"controls":{"copy":{}}},"source":"export CALLBACK_URL=https://your-ngrok-url.ngrok-free.app\nexport PORT=8081\n","lang":"bash"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Configure the ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["CALLBACK_URL"]}," as your service's webhook in the Sinch dashboard. See ",{"$$mdtype":"Tag","name":"MarkdownLink","attributes":{"href":"/docs/voice-2.0/tutorials/inbound-pstn"},"children":["Handle Inbound PSTN Calls"]}," for the full inbound webhook contract (CloudEvents headers, body shape, signature verification)."]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"first-success-record-one-outbound-call-fastest-path","__idx":7},"children":["First success: record one outbound call (fastest path)"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Once you have exported the Sinch credentials, ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["SINCH_NUMBER"]},", ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["DESTINATION_NUMBER"]},", ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["STORAGE_DESTINATION_URL"]},", and ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["STORAGE_CREDENTIALS"]},", save the script below as ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["trigger-call.sh"]}," and run ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["bash trigger-call.sh"]},":"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"bash","header":{"controls":{"copy":{}}},"source":"#!/bin/bash\n# Sinch Recording & Transcription: trigger an outbound call with inline recording SVAML.\n# The call is recorded immediately when answered; the file is uploaded to cloud storage.\n\nset -e\n\n: \"${PROJECT_ID:?ERROR: PROJECT_ID is not set.}\"\n: \"${KEY_ID:?ERROR: KEY_ID is not set.}\"\n: \"${KEY_SECRET:?ERROR: KEY_SECRET is not set.}\"\n: \"${SINCH_NUMBER:?ERROR: SINCH_NUMBER is not set.}\"\n: \"${DESTINATION_NUMBER:?ERROR: DESTINATION_NUMBER is not set.}\"\n: \"${STORAGE_DESTINATION_URL:?ERROR: STORAGE_DESTINATION_URL is not set (e.g. s3://my-bucket/recordings/).}\"\n: \"${STORAGE_CREDENTIALS:?ERROR: STORAGE_CREDENTIALS is not set (e.g. ACCESS_KEY:SECRET:REGION).}\"\n\n# Detect storage provider from the destination URL prefix\nif echo \"${STORAGE_DESTINATION_URL}\" | grep -q \"^s3://\"; then\n  STORAGE_DESTINATION=\"AWS\"\nelif echo \"${STORAGE_DESTINATION_URL}\" | grep -q \"^gs://\"; then\n  STORAGE_DESTINATION=\"GCP\"\nelse\n  STORAGE_DESTINATION=\"AZURE\"\nfi\n\nBASE_URL=\"https://voice.api.sinch.com/v2\"\n\necho \"Calling ${DESTINATION_NUMBER} with recording enabled (${STORAGE_DESTINATION}) ...\"\n\nBODY=$(printf '{\n  \"commands\": [\n    {\n      \"command\": \"dial\",\n      \"callName\": \"recorded-call\",\n      \"from\": { \"type\": \"PHONE\", \"phone\": { \"number\": \"%s\" } },\n      \"to\":   { \"type\": \"PHONE\", \"phone\": { \"number\": \"%s\" } },\n      \"dialTimeoutDurationSeconds\": 30,\n      \"maxCallDurationSeconds\": 3600,\n      \"events\": {\n        \"onAnswer\": [\n          {\n            \"command\": \"startRecording\",\n            \"recordingName\": \"main-recording\",\n            \"recordingOptions\": {\n              \"format\": \"MP3\",\n              \"recordingType\": \"COMBINED\",\n              \"destination\": \"%s\",\n              \"destinationUrl\": \"%s\",\n              \"credentials\": \"%s\",\n              \"transcriptionOptions\": { \"isEnabled\": true, \"locale\": \"en-US\" }\n            }\n          },\n          {\n            \"command\": \"messages\",\n            \"messagesName\": \"recording-notice\",\n            \"messages\": [\n              { \"type\": \"SAY\", \"say\": { \"text\": \"This call is being recorded.\", \"voiceName\": \"Emma\" } }\n            ]\n          }\n        ],\n        \"onHangup\": [\n          { \"command\": \"stopRecording\", \"recordingName\": \"main-recording\" }\n        ]\n      }\n    }\n  ]\n}' \"${SINCH_NUMBER}\" \"${DESTINATION_NUMBER}\" \\\n   \"${STORAGE_DESTINATION}\" \"${STORAGE_DESTINATION_URL}\" \"${STORAGE_CREDENTIALS}\")\n\nRESPONSE=$(curl -s -w \"\\n%{http_code}\" \\\n  -X POST \\\n  -u \"${KEY_ID}:${KEY_SECRET}\" \\\n  \"${BASE_URL}/projects/${PROJECT_ID}/calls\" \\\n  -H \"Content-Type: application/json\" \\\n  -d \"${BODY}\")\n\nHTTP_BODY=$(echo \"${RESPONSE}\" | head -n -1)\nHTTP_CODE=$(echo \"${RESPONSE}\" | tail -n 1)\n\nif [ \"${HTTP_CODE}\" -eq 201 ]; then\n  echo \"Call created with recording (HTTP ${HTTP_CODE}):\"\n  echo \"${HTTP_BODY}\" | (command -v jq > /dev/null && jq '.' || cat)\n  echo \"\"\n  echo \"Recording will be uploaded to: ${STORAGE_DESTINATION_URL}\"\nelse\n  echo \"ERROR: API returned HTTP ${HTTP_CODE}:\" >&2\n  echo \"${HTTP_BODY}\" >&2\n  exit 1\nfi\n","lang":"bash"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["This places an outbound call to ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["DESTINATION_NUMBER"]},". The moment it is answered:"]},{"$$mdtype":"Tag","name":"ol","attributes":{},"children":[{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["startRecording"]}," begins recording (combined audio, MP3, transcription on)."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":["A short \"This call is being recorded.\" message plays."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":["When the call ends, ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["stopRecording"]}," finalizes the recording and Sinch uploads it."]}]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["What success looks like:"]}]},{"$$mdtype":"Tag","name":"ul","attributes":{},"children":[{"$$mdtype":"Tag","name":"li","attributes":{},"children":["The API returns ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["HTTP 201"]}," with a call/session id (printed by the script)."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":["A few seconds after you hang up, an ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":[".mp3"]}," file appears in ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["s3://my-voice-recordings/recordings/"]},". The filename includes the call/session identifier for traceability."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":["Because transcription is enabled, a JSON transcript file lands alongside the audio."]}]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["If nothing shows up in the bucket, the credentials or bucket permissions are almost certainly wrong. Recording failures are ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["silent by default"]},", so wire ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["events.onFailure"]}," (see ",{"$$mdtype":"Tag","name":"MarkdownLink","attributes":{"href":"#recording-lifecycle-events"},"children":["Recording lifecycle events"]},") so a misconfigured bucket surfaces instead of being swallowed."]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":3,"id":"browser-trigger-quick-local-testing-only","__idx":8},"children":["Browser trigger (quick local testing only)"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["The same flow can run from the browser console. Calling the Sinch API directly from a browser hits CORS and exposes your key secret, so this is for quick local testing only. In production, proxy through your backend."]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"javascript","header":{"controls":{"copy":{}}},"source":"// Sinch Recording & Transcription: browser JS to trigger an outbound call with recording.\n// Note: calling the Sinch API directly from a browser will hit CORS restrictions,\n// and exposes your key secret. In production, proxy these calls through your backend.\n\n(async function sinchRecordingCall() {\n  const projectId             = \"YOUR_PROJECT_ID\";\n  const keyId                 = \"YOUR_KEY_ID\";\n  const keySecret             = \"YOUR_KEY_SECRET\";\n  const sinchNumber           = \"+1XXXXXXXXXX\";\n  const destinationNumber     = \"+1YYYYYYYYYY\";\n  const storageDestinationUrl = \"s3://my-bucket/recordings/\";\n  const storageCredentials    = \"ACCESS_KEY:SECRET_KEY:REGION\";\n\n  // Infer storage provider from the URL scheme\n  const storageDestination = storageDestinationUrl.startsWith(\"gs://\") ? \"GCP\"\n    : storageDestinationUrl.startsWith(\"s3://\") ? \"AWS\"\n    : \"AZURE\";\n\n  const baseUrl    = \"https://voice.api.sinch.com/v2\";\n  const authHeader = \"Basic \" + btoa(`${keyId}:${keySecret}`);\n\n  const payload = {\n    commands: [\n      {\n        command: \"dial\",\n        callName: \"recorded-call\",\n        from: { type: \"PHONE\", phone: { number: sinchNumber } },\n        to:   { type: \"PHONE\", phone: { number: destinationNumber } },\n        dialTimeoutDurationSeconds: 30,\n        maxCallDurationSeconds: 3600,\n        events: {\n          onAnswer: [\n            {\n              command: \"startRecording\",\n              recordingName: \"main-recording\",\n              recordingOptions: {\n                format: \"MP3\",\n                recordingType: \"COMBINED\",\n                destination: storageDestination,\n                destinationUrl: storageDestinationUrl,\n                credentials: storageCredentials,\n                transcriptionOptions: { isEnabled: true, locale: \"en-US\" }\n              }\n            },\n            {\n              command: \"messages\",\n              messagesName: \"recording-notice\",\n              messages: [\n                { type: \"SAY\", say: { text: \"This call is being recorded.\", voiceName: \"Emma\" } }\n              ]\n            }\n          ],\n          onHangup: [\n            { command: \"stopRecording\", recordingName: \"main-recording\" }\n          ]\n        }\n      }\n    ]\n  };\n\n  console.log(`Calling ${destinationNumber} with recording enabled (${storageDestination}) ...`);\n\n  const response = await fetch(\n    `${baseUrl}/projects/${projectId}/calls`,\n    {\n      method: \"POST\",\n      headers: { \"Content-Type\": \"application/json\", Authorization: authHeader },\n      body: JSON.stringify(payload)\n    }\n  );\n\n  const data = await response.json();\n\n  if (response.status === 201) {\n    console.log(\"Call created with recording:\", data);\n    console.log(\"Recording will be uploaded to:\", storageDestinationUrl);\n  } else {\n    console.error(`ERROR ${response.status}:`, data);\n  }\n})();\n","lang":"javascript"},"children":[]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"the-startrecording-command","__idx":9},"children":["The ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["startRecording"]}," command"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"json","header":{"controls":{"copy":{}}},"source":"{\n  \"command\": \"startRecording\",\n  \"recordingName\": \"main-recording\",\n  \"recordingOptions\": {\n    \"format\": \"MP3\",\n    \"recordingType\": \"COMBINED\",\n    \"destination\": \"AWS\",\n    \"destinationUrl\": \"s3://my-voice-recordings/recordings/\",\n    \"credentials\": \"ACCESS_KEY:SECRET_KEY:REGION\",\n    \"transcriptionOptions\": {\n      \"isEnabled\": true,\n      \"locale\": \"en-US\"\n    }\n  }\n}\n","lang":"json"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Fields, verified against the spec's ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["startRecording"]}," / ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["recordingOptions"]}," schemas:"]},{"$$mdtype":"Tag","name":"ul","attributes":{},"children":[{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["recordingName"]}," (optional): identifier for this recording in the session, 1 to 32 chars, no whitespace. Other commands (",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["stopRecording"]},") reference it to target a specific recording."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["recordingOptions"]}," (",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["required"]},"). Within it, ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["destination"]},", ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["destinationUrl"]},", and ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["credentials"]}," are required"]},":",{"$$mdtype":"Tag","name":"ul","attributes":{},"children":[{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["format"]},": ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["MP3"]}," (default) or ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["WAV"]},"."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["recordingType"]},": ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["COMBINED"]}," (both directions, default), ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["INBOUND"]}," (inbound stream only), or ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["OUTBOUND"]}," (outbound stream only)."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["destination"]},": ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["AWS"]}," (default), ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["GCP"]},", or ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["AZURE"]},"."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["destinationUrl"]},": bucket path where files are uploaded."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["credentials"]},": storage credentials in the destination-specific format."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["transcriptionOptions"]}," (optional): if present, ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["isEnabled"]}," is ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["required"]},"; ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["locale"]}," is a BCP-47 code (e.g. ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["en-US"]},", ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["es-ES"]},"), default ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["en-US"]},"."]}]}]}]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["startRecording"]}," is ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["non-blocking"]},", so the next SVAML command runs immediately. Recording continues until ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["stopRecording"]}," is issued or the call ends."]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"recording-from-a-webhook-inbound-calls","__idx":10},"children":["Recording from a webhook (inbound calls)"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["For inbound calls you return ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["startRecording"]}," from your webhook instead of putting it in the outbound payload. Pick the server in your language (all four behave identically). On a ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["call.incoming"]}," event the server answers, starts recording, and plays a recording notice."]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Each server reads its configuration from the exported environment variables (",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["SINCH_NUMBER"]},", ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["STORAGE_DESTINATION_URL"]},", ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["STORAGE_CREDENTIALS"]},", and optionally ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["PORT"]},"). Export them first, then start the server and expose it with ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["ngrok http <PORT>"]},". Set that URL as the service webhook (see ",{"$$mdtype":"Tag","name":"MarkdownLink","attributes":{"href":"/docs/voice-2.0/tutorials/inbound-pstn"},"children":["Handle Inbound PSTN Calls"]},")."]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":3,"id":"nodejs-express-default-port-3000","__idx":11},"children":["Node.js (Express, default PORT 3000)"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Requirements: ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["npm install express"]},". Run with ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["node server.js"]},". Use ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["\"type\": \"module\""]}," in your ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["package.json"]},"."]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"javascript","header":{"controls":{"copy":{}}},"source":"// Sinch Recording & Transcription: Express.js webhook server.\n// Handles call events and starts recording when the call is answered.\n\nimport express from \"express\";\n\nconst sinchNumber           = process.env.SINCH_NUMBER            || (() => { throw new Error(\"SINCH_NUMBER not set\"); })();\nconst storageDestinationUrl = process.env.STORAGE_DESTINATION_URL || (() => { throw new Error(\"STORAGE_DESTINATION_URL not set\"); })();\nconst storageCredentials    = process.env.STORAGE_CREDENTIALS     || (() => { throw new Error(\"STORAGE_CREDENTIALS not set\"); })();\nconst PORT = process.env.PORT || 3000;\n\n// Infer storage provider from URL scheme\nconst storageDestination = storageDestinationUrl.startsWith(\"gs://\") ? \"GCP\"\n  : storageDestinationUrl.startsWith(\"s3://\") ? \"AWS\"\n  : \"AZURE\";\n\nconst app = express();\napp.use(express.json());\n\n// POST /webhook: handles call events from Sinch\napp.post(\"/webhook\", (req, res) => {\n  const event = req.body?.event;\n  const call  = req.body?.call;\n\n  console.log(`Received event: ${event}`, call?.callId);\n\n  if (event === \"call.incoming\") {\n    // Inbound call: answer, start recording, play notice, then bridge to agent or hang up\n    const svamlResponse = {\n      commands: [\n        // Answer the inbound call\n        { command: \"answer\" },\n\n        // Start recording immediately when the inbound call is answered\n        {\n          command: \"startRecording\",\n          recordingName: \"main-recording\",\n          recordingOptions: {\n            format: \"MP3\",\n            recordingType: \"COMBINED\",        // Record both parties\n            destination: storageDestination,\n            destinationUrl: storageDestinationUrl,\n            credentials: storageCredentials,\n            transcriptionOptions: {\n              isEnabled: true,                // Generate a transcription file\n              locale: \"en-US\"\n            }\n          }\n        },\n\n        // Tell the caller the call is being recorded (legal requirement in many jurisdictions)\n        {\n          command: \"messages\",\n          messagesName: \"recording-notice\",\n          messages: [\n            {\n              type: \"SAY\",\n              say: {\n                text: \"This call may be recorded for quality and compliance purposes.\",\n                voiceName: \"Emma\"\n              }\n            }\n          ]\n        }\n\n        // Add your additional SVAML commands here, e.g.:\n        // { command: \"bridgeCall\", bridgeName: \"agent-bridge\" }\n        // { command: \"dial\", callName: \"agent\", to: { ... } }\n      ],\n      events: { onHangup: [{ command: \"hangup\" }] }\n    };\n\n    return res.status(200).json(svamlResponse);\n  }\n\n  console.log(`Unhandled event: ${event}`);\n  res.status(200).json({ commands: [] });\n});\n\napp.listen(PORT, () => {\n  console.log(`Recording webhook server listening on port ${PORT}`);\n  console.log(`Storage: ${storageDestination} -> ${storageDestinationUrl}`);\n  console.log(`(Use ngrok: ngrok http ${PORT})`);\n});\n","lang":"javascript"},"children":[]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":3,"id":"python-flask-default-port-8081","__idx":12},"children":["Python (Flask, default PORT 8081)"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Requirements: ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["pip install flask"]},". Run with ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["python server.py"]},"."]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"python","header":{"controls":{"copy":{}}},"source":"# Sinch Recording & Transcription: Flask webhook server.\n# Starts recording when a call is answered and uploads to cloud storage.\n\nimport os\nimport sys\nfrom flask import Flask, request, jsonify\n\nsinch_number            = os.environ.get(\"SINCH_NUMBER\")\nstorage_destination_url = os.environ.get(\"STORAGE_DESTINATION_URL\")\nstorage_credentials     = os.environ.get(\"STORAGE_CREDENTIALS\")\n\nfor var, name in [\n    (sinch_number, \"SINCH_NUMBER\"),\n    (storage_destination_url, \"STORAGE_DESTINATION_URL\"),\n    (storage_credentials, \"STORAGE_CREDENTIALS\"),\n]:\n    if not var:\n        print(f\"ERROR: {name} is not set.\", file=sys.stderr)\n        sys.exit(1)\n\n# Infer storage provider from URL scheme\nif storage_destination_url.startswith(\"gs://\"):\n    storage_destination = \"GCP\"\nelif storage_destination_url.startswith(\"s3://\"):\n    storage_destination = \"AWS\"\nelse:\n    storage_destination = \"AZURE\"\n\nport = int(os.environ.get(\"PORT\", 8081))\napp = Flask(__name__)\n\n\n@app.route(\"/webhook\", methods=[\"POST\"])\ndef webhook():\n    \"\"\"Handles Sinch call events and starts recording on answer.\"\"\"\n    body  = request.json\n    event = body.get(\"event\")\n    call  = body.get(\"call\", {})\n\n    print(f\"Received event: {event}, callId: {call.get('callId')}\")\n\n    if event == \"call.incoming\":\n        # Inbound call: answer, start recording, play notice\n        svaml_response = {\n            \"commands\": [\n                # Answer the inbound call\n                {\"command\": \"answer\"},\n\n                # Start recording immediately on answer\n                {\n                    \"command\": \"startRecording\",\n                    \"recordingName\": \"main-recording\",\n                    \"recordingOptions\": {\n                        \"format\": \"MP3\",\n                        \"recordingType\": \"COMBINED\",    # Both parties recorded\n                        \"destination\": storage_destination,\n                        \"destinationUrl\": storage_destination_url,\n                        \"credentials\": storage_credentials,\n                        \"transcriptionOptions\": {\n                            \"isEnabled\": True,          # Generate transcript alongside audio\n                            \"locale\": \"en-US\"\n                        }\n                    }\n                },\n\n                # Inform the caller the call is being recorded\n                {\n                    \"command\": \"messages\",\n                    \"messagesName\": \"recording-notice\",\n                    \"messages\": [\n                        {\n                            \"type\": \"SAY\",\n                            \"say\": {\n                                \"text\": \"This call may be recorded for quality and compliance purposes.\",\n                                \"voiceName\": \"Emma\"\n                            }\n                        }\n                    ]\n                }\n\n                # Add further SVAML commands here:\n                # {\"command\": \"bridgeCall\", \"bridgeName\": \"agent-bridge\"},\n                # {\"command\": \"dial\", \"callName\": \"agent\", \"to\": {...}}\n            ],\n            \"events\": {\"onHangup\": [{\"command\": \"hangup\"}]},\n        }\n        return jsonify(svaml_response), 200\n\n    print(f\"Unhandled event: {event}\")\n    return jsonify({\"commands\": []}), 200\n\n\nif __name__ == \"__main__\":\n    print(f\"Recording webhook server listening on port {port}\")\n    print(f\"Storage: {storage_destination} -> {storage_destination_url}\")\n    print(f\"(Use ngrok: ngrok http {port})\")\n    app.run(host=\"0.0.0.0\", port=port)\n","lang":"python"},"children":[]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":3,"id":"php-slim-4-default-port-3000","__idx":13},"children":["PHP (Slim 4, default PORT 3000)"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Requirements: ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["composer require slim/slim slim/psr7 nyholm/psr7"]},". Run with ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["php -S 0.0.0.0:3000 server.php"]},". Slim reads config via ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["getenv"]},", which picks up your exported variables directly."]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"php","header":{"controls":{"copy":{}}},"source":"<?php\n// Sinch Recording & Transcription: Slim Framework 4 webhook server.\n// Starts recording when a call is answered and uploads to cloud storage.\n\nuse Psr\\Http\\Message\\ResponseInterface as Response;\nuse Psr\\Http\\Message\\ServerRequestInterface as Request;\nuse Slim\\Factory\\AppFactory;\n\nrequire __DIR__ . '/vendor/autoload.php';\n\n$sinchNumber           = getenv('SINCH_NUMBER')            ?: die(\"ERROR: SINCH_NUMBER not set.\\n\");\n$storageDestinationUrl = getenv('STORAGE_DESTINATION_URL') ?: die(\"ERROR: STORAGE_DESTINATION_URL not set.\\n\");\n$storageCredentials    = getenv('STORAGE_CREDENTIALS')     ?: die(\"ERROR: STORAGE_CREDENTIALS not set.\\n\");\n\n// Infer storage provider from URL scheme\nif (str_starts_with($storageDestinationUrl, 'gs://')) {\n    $storageDestination = 'GCP';\n} elseif (str_starts_with($storageDestinationUrl, 's3://')) {\n    $storageDestination = 'AWS';\n} else {\n    $storageDestination = 'AZURE';\n}\n\n$app = AppFactory::create();\n$app->addBodyParsingMiddleware();\n\n$app->post('/webhook', function (Request $request, Response $response)\n    use ($sinchNumber, $storageDestination, $storageDestinationUrl, $storageCredentials) {\n\n    $body  = $request->getParsedBody();\n    $event = $body['event'] ?? null;\n    $call  = $body['call']  ?? [];\n\n    error_log(\"Received event: {$event}, callId: \" . ($call['callId'] ?? ''));\n\n    if ($event === 'call.incoming') {\n        $svaml = [\n            'commands' => [\n                // Answer the inbound call\n                ['command' => 'answer'],\n\n                // Start recording immediately when the call is answered\n                [\n                    'command'       => 'startRecording',\n                    'recordingName' => 'main-recording',\n                    'recordingOptions' => [\n                        'format'        => 'MP3',\n                        'recordingType' => 'COMBINED',      // Both parties\n                        'destination'   => $storageDestination,\n                        'destinationUrl' => $storageDestinationUrl,\n                        'credentials'   => $storageCredentials,\n                        'transcriptionOptions' => [\n                            'isEnabled' => true,            // Auto-transcribe the recording\n                            'locale'    => 'en-US',\n                        ],\n                    ],\n                ],\n\n                // Inform the caller the call is being recorded\n                [\n                    'command'       => 'messages',\n                    'messagesName'  => 'recording-notice',\n                    'messages' => [\n                        [\n                            'type' => 'SAY',\n                            'say'  => [\n                                'text'      => 'This call may be recorded for quality and compliance purposes.',\n                                'voiceName' => 'Emma',\n                            ],\n                        ],\n                    ],\n                ],\n\n                // Add your routing SVAML here:\n                // ['command' => 'bridgeCall', 'bridgeName' => 'agent-bridge'],\n            ],\n            'events' => ['onHangup' => [['command' => 'hangup']]],\n        ];\n\n        $response->getBody()->write(json_encode($svaml));\n        return $response->withHeader('Content-Type', 'application/json')->withStatus(200);\n    }\n\n    error_log(\"Unhandled event: {$event}\");\n    $response->getBody()->write(json_encode(['commands' => []]));\n    return $response->withHeader('Content-Type', 'application/json')->withStatus(200);\n});\n\n$port = (int)(getenv('PORT') ?: 3000);\necho \"Recording webhook server listening on port {$port}\\n\";\necho \"Storage: {$storageDestination} -> {$storageDestinationUrl}\\n\";\necho \"(Use ngrok: ngrok http {$port})\\n\";\n\n$app->run();\n","lang":"php"},"children":[]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":3,"id":"java-spring-boot-default-port-3000","__idx":14},"children":["Java (Spring Boot, default PORT 3000)"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Add ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["spring-boot-starter-web"]}," to your ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["pom.xml"]}," and run with ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["mvn spring-boot:run"]},". Spring reads config via ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["System.getenv"]},", which picks up your exported variables directly."]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"java","header":{"controls":{"copy":{}}},"source":"// Sinch Recording & Transcription: Spring Boot webhook server.\n// Starts recording when a call is answered and uploads to cloud storage.\n\npackage com.sinch.tutorials.recording;\n\nimport org.springframework.boot.SpringApplication;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.bind.annotation.*;\n\nimport java.util.List;\nimport java.util.Map;\n\n@SpringBootApplication\n@RestController\npublic class Server {\n\n    private final String storageDestination;\n    private final String storageDestinationUrl;\n    private final String storageCredentials;\n\n    public Server() {\n        String sinchNumber         = requireEnv(\"SINCH_NUMBER\");\n        this.storageDestinationUrl = requireEnv(\"STORAGE_DESTINATION_URL\");\n        this.storageCredentials    = requireEnv(\"STORAGE_CREDENTIALS\");\n\n        // Infer provider from URL scheme\n        if (storageDestinationUrl.startsWith(\"gs://\")) {\n            this.storageDestination = \"GCP\";\n        } else if (storageDestinationUrl.startsWith(\"s3://\")) {\n            this.storageDestination = \"AWS\";\n        } else {\n            this.storageDestination = \"AZURE\";\n        }\n    }\n\n    public static void main(String[] args) {\n        String port = System.getenv().getOrDefault(\"PORT\", \"3000\");\n        System.setProperty(\"server.port\", port);\n        SpringApplication.run(Server.class, args);\n        System.out.println(\"Recording webhook server started on port \" + port);\n    }\n\n    /** POST /webhook: handles Sinch call events and starts recording on answer */\n    @PostMapping(\"/webhook\")\n    public ResponseEntity<Map<String, Object>> webhook(@RequestBody Map<String, Object> body) {\n        String event = (String) body.getOrDefault(\"event\", \"\");\n        @SuppressWarnings(\"unchecked\")\n        Map<String, Object> call = (Map<String, Object>) body.getOrDefault(\"call\", Map.of());\n\n        System.out.println(\"Received event: \" + event + \", callId: \" + call.get(\"callId\"));\n\n        if (\"call.incoming\".equals(event)) {\n            Map<String, Object> svaml = Map.of(\n                \"commands\", List.of(\n                    // Answer the inbound call\n                    Map.of(\"command\", \"answer\"),\n\n                    // Start recording immediately when the call is answered\n                    Map.of(\n                        \"command\", \"startRecording\",\n                        \"recordingName\", \"main-recording\",\n                        \"recordingOptions\", Map.of(\n                            \"format\", \"MP3\",\n                            \"recordingType\", \"COMBINED\",       // Both parties recorded\n                            \"destination\", storageDestination,\n                            \"destinationUrl\", storageDestinationUrl,\n                            \"credentials\", storageCredentials,\n                            \"transcriptionOptions\", Map.of(\n                                \"isEnabled\", true,             // Generate transcript\n                                \"locale\", \"en-US\"\n                            )\n                        )\n                    ),\n\n                    // Inform the caller the call is being recorded\n                    Map.of(\n                        \"command\", \"messages\",\n                        \"messagesName\", \"recording-notice\",\n                        \"messages\", List.of(\n                            Map.of(\n                                \"type\", \"SAY\",\n                                \"say\", Map.of(\n                                    \"text\", \"This call may be recorded for quality and compliance purposes.\",\n                                    \"voiceName\", \"Emma\"\n                                )\n                            )\n                        )\n                    )\n\n                    // Add routing commands here, e.g.:\n                    // Map.of(\"command\", \"bridgeCall\", \"bridgeName\", \"agent-bridge\")\n                ),\n                \"events\", Map.of(\"onHangup\", List.of(Map.of(\"command\", \"hangup\")))\n            );\n\n            return ResponseEntity.ok(svaml);\n        }\n\n        System.out.println(\"Unhandled event: \" + event);\n        return ResponseEntity.ok(Map.of(\"commands\", List.of()));\n    }\n\n    private static String requireEnv(String name) {\n        String value = System.getenv(name);\n        if (value == null || value.isBlank()) {\n            System.err.println(\"ERROR: \" + name + \" is not set.\");\n            System.exit(1);\n        }\n        return value;\n    }\n}\n","lang":"java"},"children":[]},{"$$mdtype":"Tag","name":"blockquote","attributes":{},"children":[{"$$mdtype":"Tag","name":"p","attributes":{},"children":["The webhook body is ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["{ event, call }"]},"; read identifiers off the nested ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["call"]}," object (e.g. ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["call.callId"]},", ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["call.sessionId"]},"), consistent with ",{"$$mdtype":"Tag","name":"MarkdownLink","attributes":{"href":"/docs/voice-2.0/tutorials/inbound-pstn"},"children":["Handle Inbound PSTN Calls"]},". The servers here log ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["call.callId"]},"."]}]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["The equivalent SVAML the server returns on ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["call.incoming"]}," looks like this:"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"json","header":{"controls":{"copy":{}}},"source":"{\n  \"commands\": [\n    { \"command\": \"answer\" },\n    {\n      \"command\": \"startRecording\",\n      \"recordingName\": \"main-recording\",\n      \"recordingOptions\": {\n        \"format\": \"MP3\",\n        \"recordingType\": \"COMBINED\",\n        \"destination\": \"AWS\",\n        \"destinationUrl\": \"s3://my-voice-recordings/recordings/\",\n        \"credentials\": \"ACCESS_KEY:SECRET_KEY:REGION\",\n        \"transcriptionOptions\": { \"isEnabled\": true, \"locale\": \"en-US\" }\n      }\n    },\n    {\n      \"command\": \"messages\",\n      \"messagesName\": \"recording-notice\",\n      \"messages\": [\n        { \"type\": \"SAY\", \"say\": { \"text\": \"This call may be recorded for quality and compliance purposes.\", \"voiceName\": \"Emma\" } }\n      ]\n    }\n  ],\n  \"events\": { \"onHangup\": [{ \"command\": \"hangup\" }] }\n}\n","lang":"json"},"children":[]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"stopping-a-recording-mid-call","__idx":15},"children":["Stopping a recording mid-call"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Issue ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["stopRecording"]}," referencing the ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["recordingName"]}," (the only required field besides ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["command"]},"):"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"json","header":{"controls":{"copy":{}}},"source":"{ \"command\": \"stopRecording\", \"recordingName\": \"main-recording\" }\n","lang":"json"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["You can record multiple distinct streams in one session by giving them distinct ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["recordingName"]}," values, then stop them independently."]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"after-the-call-ends","__idx":16},"children":["After the call ends"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Sinch uploads the recording to your bucket. The filename includes the call/session identifier. If transcription is enabled, a JSON transcript file is uploaded alongside the audio."]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"recording-lifecycle-events","__idx":17},"children":["Recording lifecycle events"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["startRecording"]}," accepts an optional ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["events"]}," block (",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["recordingEvents"]}," schema). The spec defines exactly two handlers:"]},{"$$mdtype":"Tag","name":"div","attributes":{"className":"md-table-wrapper"},"children":[{"$$mdtype":"Tag","name":"table","attributes":{"className":"md"},"children":[{"$$mdtype":"Tag","name":"thead","attributes":{},"children":[{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"th","attributes":{"data-label":"Event"},"children":["Event"]},{"$$mdtype":"Tag","name":"th","attributes":{"data-label":"When it fires"},"children":["When it fires"]}]}]},{"$$mdtype":"Tag","name":"tbody","attributes":{},"children":[{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["onFinish"]}]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":["The recording was successfully stopped. Note: this does ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["not"]}," mean the file has been delivered to your bucket yet; it may still be in transit."]}]},{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["onFailure"]}]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":["The recording failed to start (auth error, missing bucket, bad credentials, etc.). ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["If omitted, failures are silently ignored and the call flow continues."]}]}]}]}]}]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Announce a problem on the call instead of recording silently failing:"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"json","header":{"controls":{"copy":{}}},"source":"{\n  \"command\": \"startRecording\",\n  \"recordingName\": \"compliance\",\n  \"recordingOptions\": { \"destination\": \"AWS\", \"destinationUrl\": \"s3://...\", \"credentials\": \"...\" },\n  \"events\": {\n    \"onFailure\": [\n      {\n        \"command\": \"messages\",\n        \"messages\": [\n          { \"type\": \"SAY\", \"say\": { \"text\": \"We are unable to record this call. Goodbye.\", \"voiceName\": \"Emma\" } }\n        ],\n        \"events\": { \"onFinish\": [{ \"command\": \"hangup\" }] }\n      }\n    ]\n  }\n}\n","lang":"json"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["The example servers in this tutorial do ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["not"]}," wire ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["onFailure"]},". Add it for any production flow that legally requires a recording."]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"other-storage-providers","__idx":18},"children":["Other storage providers"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["The credential string is provider-specific. AWS is shown above. For the others, set ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["STORAGE_DESTINATION_URL"]}," so the examples infer the right ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["destination"]},":"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"bash","header":{"controls":{"copy":{}}},"source":"# Google Cloud Storage  (destination inferred: GCP)\nexport STORAGE_DESTINATION_URL=gs://my-gcs-bucket/recordings/\nexport STORAGE_CREDENTIALS=<service-account credential>   # e.g. base64-encoded service-account JSON\n\n# Azure Blob Storage     (destination inferred: AZURE)\nexport STORAGE_DESTINATION_URL=https://myaccount.blob.core.windows.net/recordings/\nexport STORAGE_CREDENTIALS=<Azure storage credential>     # e.g. connection string or SAS token\n","lang":"bash"},"children":[]},{"$$mdtype":"Tag","name":"ul","attributes":{},"children":[{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["GCS"]},": a service account with the ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["Storage Object Creator"]}," role on the bucket."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Azure"]},": a storage account with a Blob container plus an access key or SAS token."]}]},{"$$mdtype":"Tag","name":"blockquote","attributes":{},"children":[{"$$mdtype":"Tag","name":"p","attributes":{},"children":["The exact credential encoding for GCP and Azure (base64-JSON vs. raw JSON, connection string vs. SAS token) is not specified in the OpenAPI document. The spec only types ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["credentials"]}," as a free-form string and gives an AWS-only example. Confirm the GCP/Azure formats against current Sinch product docs before relying on them."]}]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"production-readiness-checklist","__idx":19},"children":["Production-readiness checklist"]},{"$$mdtype":"Tag","name":"div","attributes":{"className":"md-table-wrapper"},"children":[{"$$mdtype":"Tag","name":"table","attributes":{"className":"md"},"children":[{"$$mdtype":"Tag","name":"thead","attributes":{},"children":[{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"th","attributes":{"data-label":"Concern"},"children":["Concern"]},{"$$mdtype":"Tag","name":"th","attributes":{"data-label":"What to do"},"children":["What to do"]}]}]},{"$$mdtype":"Tag","name":"tbody","attributes":{},"children":[{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Consent"]}]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":["In many jurisdictions you must announce recording ",{"$$mdtype":"Tag","name":"em","attributes":{},"children":["before"]}," ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["startRecording"]}," runs. Play a ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["SAY"]}," message first (the examples play one right after, which may be too late for strict regimes)."]}]},{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Bucket permissions"]}]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":["Grant the minimum (",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["s3:PutObject"]}," etc.), not full bucket access. Rotate keys periodically."]}]},{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Lifecycle / cost"]}]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":["Apply a bucket lifecycle policy to age recordings to cheaper storage and delete after your retention period."]}]},{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Multiple recordings"]}]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":["Each ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["recordingName"]}," produces a separate output. Use distinct names per leg."]}]},{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Inbound-only vs combined"]}]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":["If only one party consented, use ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["recordingType: INBOUND"]}," or ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["OUTBOUND"]},". Default ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["COMBINED"]}," captures both."]}]},{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Transcription locale"]}]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":["Default ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["en-US"]},". Set explicitly for non-English calls, since a wrong locale yields a wrong transcript."]}]},{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Failure handling"]}]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":["Wire ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["events.onFailure"]}," so a misconfigured bucket doesn't silently swallow a recording."]}]},{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Secrets in the shell"]}]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":["Exported variables live in your shell environment and can appear in shell history. Use a secrets manager or a restricted, non-committed profile file in production."]}]}]}]}]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"command-reference","__idx":20},"children":["Command reference"]},{"$$mdtype":"Tag","name":"ul","attributes":{},"children":[{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["startRecording"]},": non-blocking; requires ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["recordingOptions"]},". Optional ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["recordingName"]}," (1 to 32 chars, no whitespace) and ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["events"]},"."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["recordingOptions"]},": requires ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["destination"]},", ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["destinationUrl"]},", ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["credentials"]},". Optional ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["format"]},", ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["recordingType"]},", ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["transcriptionOptions"]},"."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["format"]},": ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["MP3"]}," (default) / ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["WAV"]},"."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["recordingType"]},": ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["COMBINED"]}," (default) / ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["INBOUND"]}," / ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["OUTBOUND"]},"."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["destination"]},": ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["AWS"]}," (default) / ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["GCP"]}," / ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["AZURE"]},"."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["transcriptionOptions"]},": ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["isEnabled"]}," required when present; ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["locale"]}," defaults to ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["en-US"]},"."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["stopRecording"]},": non-blocking; requires ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["recordingName"]},"."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["recordingEvents"]},": ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["onFinish"]},", ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["onFailure"]}," (both optional)."]}]}]},"headings":[{"value":"Record Calls & Transcribe Audio","id":"record-calls--transcribe-audio","depth":1},{"value":"Overview","id":"overview","depth":2},{"value":"Real-life examples","id":"real-life-examples","depth":2},{"value":"Setup","id":"setup","depth":2},{"value":"1. Sinch credentials","id":"1-sinch-credentials","depth":3},{"value":"2. Storage bucket + credentials (the part that takes the most time)","id":"2-storage-bucket--credentials-the-part-that-takes-the-most-time","depth":3},{"value":"3. (Inbound only) a public callback URL","id":"3-inbound-only-a-public-callback-url","depth":3},{"value":"First success: record one outbound call (fastest path)","id":"first-success-record-one-outbound-call-fastest-path","depth":2},{"value":"Browser trigger (quick local testing only)","id":"browser-trigger-quick-local-testing-only","depth":3},{"value":"The startRecording command","id":"the-startrecording-command","depth":2},{"value":"Recording from a webhook (inbound calls)","id":"recording-from-a-webhook-inbound-calls","depth":2},{"value":"Node.js (Express, default PORT 3000)","id":"nodejs-express-default-port-3000","depth":3},{"value":"Python (Flask, default PORT 8081)","id":"python-flask-default-port-8081","depth":3},{"value":"PHP (Slim 4, default PORT 3000)","id":"php-slim-4-default-port-3000","depth":3},{"value":"Java (Spring Boot, default PORT 3000)","id":"java-spring-boot-default-port-3000","depth":3},{"value":"Stopping a recording mid-call","id":"stopping-a-recording-mid-call","depth":2},{"value":"After the call ends","id":"after-the-call-ends","depth":2},{"value":"Recording lifecycle events","id":"recording-lifecycle-events","depth":2},{"value":"Other storage providers","id":"other-storage-providers","depth":2},{"value":"Production-readiness checklist","id":"production-readiness-checklist","depth":2},{"value":"Command reference","id":"command-reference","depth":2}],"frontmatter":{"seo":{"title":"Record Calls & Transcribe Audio"}},"lastModified":"2026-08-31T13:48:15.000Z","pagePropGetterError":{"message":"","name":""}},"slug":"/docs/voice-2.0/tutorials/recording-and-transcription","userData":{"isAuthenticated":false,"teams":["anonymous"]},"isPublic":true}