{"templateId":"markdown","sharedDataIds":{"sidebar":"sidebar-docs/voice-2.0/sidebars.yaml"},"props":{"metadata":{"markdoc":{"tagList":["partial","admonition"]},"product":"Voice v2 (preview)","type":"markdown"},"seo":{"title":"Handle Inbound PSTN Calls","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 (legacy/GA)","description":"Which Voice API? If you are building something using voice AI agents, use the Voice API v2. To learn more, you can read [Why migrate to v2?](/docs/voice-2.0/migration.md) or [What's new?](/docs/voice-2.0/whatsnew.md).\n\nThe 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"],"excludeFiles":["docs/voice/index.md"]},{"title":"Voice API v2 (recommended for new builds/preview)","description":"Which Voice API? If you are building something using voice AI agents, use the Voice API v2. To learn more, you can read [Why migrate to v2?](/docs/voice-2.0/migration.md) or [What's new?](/docs/voice-2.0/whatsnew.md).\n\nThe Voice API v2 enables embedding voice calling into applications for virtually limitless use cases. From voice-enabling AI agents to allow real-time conversational AI workflows over the phone, to number masking for privacy, automated appointment reminders, and outreach campaigns with answering machine detection, to bridging calls between PSTN and SIP endpoints, the API offers the flexibility and control needed for modern communications. The Sinch Programmable Voice is a cloud-native global voice platform, composed by multiple Inbound and Outbound voice channels. All these channels can be combined using our powerful markup language called SVAML v2, creating a toolbox capable of building almost limitless use cases. Our SVAML uses the concept of commands, and customers can use a combination of our commands to interact with calls in all channels, in order to model from the most basic to highly complex scenarios, required to fulfill their voice calling needs.","includeFiles":["docs/voice-2.0/**/*.md"],"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/ai-support.md","docs/functions/reference/**/*.md"],"excludeFiles":["docs/functions/functions/index.md"]},{"title":"CLI","description":"One command line for Sinch numbers, messaging, voice, fax, SIP trunking and porting, 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":"Accordion","attributes":{"title":"Sinch Voice API: Choosing between v1 and v2","expanded":false},"children":[{"$$mdtype":"Tag","name":"p","attributes":{},"children":["For all new projects, please use Voice API v2. This is especially important for projects involving:"]},{"$$mdtype":"Tag","name":"ul","attributes":{},"children":[{"$$mdtype":"Tag","name":"li","attributes":{},"children":["AI capabilities like Streams and Voice Relay"]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":["Voice campaigns like Batch calling"]}]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["You should only use the older Voice API v1 if one of the following is a strict requirement:"]},{"$$mdtype":"Tag","name":"ul","attributes":{},"children":[{"$$mdtype":"Tag","name":"li","attributes":{},"children":["The project must be in General Availability (GA)."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":["The project requires In-App voice or Conferencing (until these features are available in v2)."]}]}]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":1,"id":"handle-inbound-pstn-calls","__idx":0},"children":["Handle Inbound PSTN Calls"]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"overview","__idx":1},"children":["Overview"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["When someone dials a Sinch virtual number assigned to your project, the Voice API v2 sends a ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["call.incoming"]}]}," webhook to the URL configured on the ",{"$$mdtype":"Tag","name":"em","attributes":{},"children":["service"]}," that owns the number. Your backend answers with the SVAML commands to run for that call: greet the caller, gather input, bridge to an agent, route to an AI assistant, anything."]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["A service can handle inbound calls in one of three ways (its ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["callBehavior.type"]},"):"]},{"$$mdtype":"Tag","name":"ul","attributes":{},"children":[{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["WEBHOOK"]}]},": Sinch POSTs every event to your URL and you decide what to do at runtime. This is the dynamic option and the focus of this tutorial."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["STATIC"]}]},": Sinch executes a fixed SVAML script stored on the service (",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["callBehavior.static.commands"]},"). No backend needed. Static services do ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["not"]}," trigger the incoming webhook."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["NONE"]}]},": Incoming calls are not handled (outbound calls can still be created via the API)."]}]},{"$$mdtype":"Tag","name":"Admonition","attributes":{"type":"success","name":"Build this with AI or the CLI"},"children":[{"$$mdtype":"Tag","name":"p","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":[{"$$mdtype":"Tag","name":"MarkdownLink","attributes":{"href":"https://dashboard.sinch.com/functions/templates/node/inbound-call-bridge/deploy"},"children":["Deploy the inbound-call-bridge template"]}]},": it greets the caller and bridges to ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["AGENT_NUMBER"]},", with a fallback number and a no-answer message. Fill in the values, then either deploy it as it is or take it into Assistant and change it in chat."]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Describe your own instead:"]}," ",{"$$mdtype":"Tag","name":"MarkdownLink","attributes":{"href":"https://dashboard.sinch.com/functions/builder/new?type=voice"},"children":["open Assistant"]}," and describe what the call should do. It writes, runs and deploys the function for you."]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Or use the CLI:"]}," Execute ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["sinch functions init inbound-call-bridge"]},", then ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["sinch functions dev"]}," so real calls reach your server without a tunnel. Execute ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["sinch functions deploy"]}," to put it in production once you're satisfied with it. See the ",{"$$mdtype":"Tag","name":"MarkdownLink","attributes":{"href":"/docs/functions/functions/quickstart"},"children":["Sinch Functions quickstart"]},"."]}]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"what-youll-build","__idx":2},"children":["What you'll build"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["A webhook server that, on ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["call.incoming"]},", answers the call, plays a greeting, and bridges the caller to an agent phone number. By the end you'll place a real call to your Sinch number and watch it get answered."]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Every example in this tutorial is shown in three languages: ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["bash"]}," (curl / ncat), ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Python"]}," (requests / Flask), and ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Node.js"]}," (fetch / Express). Pick one column and stick with it, or mix freely; they are functionally identical."]},{"$$mdtype":"Tag","name":"hr","attributes":{},"children":[]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"setup","__idx":3},"children":["Setup"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Export the following variables in every shell you use for this tutorial (the one running the server, and the one running the API-call examples). The Python and Node.js examples read the same variables from the environment, so the exports work for all three languages:"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"bash","header":{"controls":{"copy":{}}},"source":"# API credentials - Sinch Dashboard (https://dashboard.sinch.com) -> Access keys\nexport PROJECT_ID=\"your-project-id\"\nexport KEY_ID=\"your-key-id\"\nexport KEY_SECRET=\"your-key-secret\"\n\n# The service that owns your Sinch number - Dashboard -> Voice -> Services\n# (or GET /v2/projects/{projectId}/services). This is the service whose\n# callBehavior you'll switch to WEBHOOK.\nexport SERVICE_ID=\"your-service-id\"\n\n# Your Sinch virtual number, E.164, routed to the service above\nexport SINCH_NUMBER=\"+14045001000\"\n\n# The agent number the call bridges to, E.164 - any phone you can answer\nexport DESTINATION_NUMBER=\"+14155559876\"\n\n# Local server port\nexport PORT=3000\n","lang":"bash"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Tools and dependencies per language:"]},{"$$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":"Language"},"children":["Language"]},{"$$mdtype":"Tag","name":"th","attributes":{"data-label":"Server"},"children":["Server"]},{"$$mdtype":"Tag","name":"th","attributes":{"data-label":"API-call examples"},"children":["API-call examples"]}]}]},{"$$mdtype":"Tag","name":"tbody","attributes":{},"children":[{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["bash"]}]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["ncat"]}," (from the nmap package) + ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["jq"]},"; demo/dev only"]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["curl"]}]}]},{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Python 3.8+"]}]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":["Flask: ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["pip install flask"]}]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["pip install requests"]}]}]},{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Node.js 18+"]}]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":["Express (ES modules): ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["npm install express"]}]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":["Built-in ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["fetch"]},", no install"]}]}]}]}]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["You'll also need ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":[{"$$mdtype":"Tag","name":"MarkdownLink","attributes":{"href":"https://ngrok.com"},"children":["ngrok"]}]}," (or any tunnel) to expose your local server during development. All API calls authenticate with HTTP Basic auth using ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["KEY_ID"]}," / ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["KEY_SECRET"]},"."]},{"$$mdtype":"Tag","name":"hr","attributes":{},"children":[]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"step-0---smoke-test-your-svaml-no-phone-needed","__idx":4},"children":["Step 0 - Smoke-test your SVAML (no phone needed)"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Before any tunneling, confirm the SVAML you intend to return is valid. ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["POST /svaml/validate"]}," checks a payload using the same rules as a live call and returns ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["{ \"isValid\": true | false, \"errors\": [...] }"]},"."]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["bash"]}]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"bash","header":{"controls":{"copy":{}}},"source":"curl -s -X POST \\\n  -u \"$KEY_ID:$KEY_SECRET\" \\\n  \"https://voice.api.sinch.com/v2/projects/$PROJECT_ID/svaml/validate\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n          \"svaml\": {\"commands\":[\n            { \"command\": \"answer\" },\n            { \"command\": \"messages\",\n              \"messages\": [\n                { \"type\": \"SAY\", \"say\": { \"text\": \"Welcome to Acme.\", \"voiceName\": \"Emma\" } }\n              ] }\n          ]}\n  }'\n","lang":"bash"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Python"]}," (",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["validate.py"]},")"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"python","header":{"controls":{"copy":{}}},"source":"import os\nimport requests\n\nresp = requests.post(\n    f\"https://voice.api.sinch.com/v2/projects/{os.environ['PROJECT_ID']}/svaml/validate\",\n    auth=(os.environ[\"KEY_ID\"], os.environ[\"KEY_SECRET\"]),\n    json = {\n      \"svaml\": {\n        \"commands\": [{\n            \"command\": \"answer\"\n          }, {\n            \"command\": \"messages\",\n            \"messages\": [{\n                \"type\": \"SAY\",\n                \"say\": {\n                  \"text\": \"Welcome to Acme.\",\n                  \"voiceName\": \"Emma\"\n                }\n              }\n            ]\n          }\n        ]\n      }\n    }\n)\nprint(resp.status_code, resp.json())\n","lang":"python"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Node.js"]}," (",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["validate.js"]},")"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"javascript","header":{"controls":{"copy":{}}},"source":"const auth = Buffer.from(`${process.env.KEY_ID}:${process.env.KEY_SECRET}`).toString(\"base64\");\n\nconst resp = await fetch(\n  `https://voice.api.sinch.com/v2/projects/${process.env.PROJECT_ID}/svaml/validate`,\n  {\n    method: \"POST\",\n    headers: { Authorization: `Basic ${auth}`, \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({\n      svaml: {commands: [\n        { command: \"answer\" },\n        { command: \"messages\",\n          messages: [\n            { type: \"SAY\", say: { text: \"Welcome to Acme.\", voiceName: \"Emma\" } }\n          ] }\n      ] }\n    })\n  }\n);\nconsole.log(resp.status, await resp.json());\n","lang":"javascript"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Expected: ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["{\"isValid\":true}"]},". A ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["200"]}," here means validation ",{"$$mdtype":"Tag","name":"em","attributes":{},"children":["ran"]},", not that the payload passed; always read ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["isValid"]},"."]},{"$$mdtype":"Tag","name":"hr","attributes":{},"children":[]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"step-1---write-and-start-the-webhook-server","__idx":5},"children":["Step 1 - Write and start the webhook server"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["All three servers behave identically. They read ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["SINCH_NUMBER"]}," and ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["DESTINATION_NUMBER"]}," from the environment (the variables you exported above), exit with an error if either is missing, and listen on ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["PORT"]}," (default ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["3000"]},") at ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["POST /voice/events"]},":"]},{"$$mdtype":"Tag","name":"ul","attributes":{},"children":[{"$$mdtype":"Tag","name":"li","attributes":{},"children":["On ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["call.incoming"]}]},": answer, play a greeting, then dial ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["DESTINATION_NUMBER"]}," and bridge the two legs."]}]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["bash"]}," (",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["server.sh"]},")"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"bash","header":{"controls":{"copy":{}}},"source":"#!/usr/bin/env bash\n# Sinch Voice API v2 - Inbound PSTN webhook server (bash + ncat, demo only).\n# Handles call.incoming by greeting the caller, then dialing an agent and bridging.\n#\n# Requirements: ncat (nmap package), jq\n# Run:    bash server.sh   (with SINCH_NUMBER / DESTINATION_NUMBER exported)\n# Expose: ngrok http 3000\n\nset -euo pipefail\n\n: \"${SINCH_NUMBER:?ERROR: export SINCH_NUMBER first}\"\n: \"${DESTINATION_NUMBER:?ERROR: export DESTINATION_NUMBER first}\"\nPORT=\"${PORT:-3000}\"\n\n# ncat re-invokes this script with --handle for each connection.\nif [ \"${1:-}\" = \"--handle\" ]; then\n  content_length=0\n  while IFS= read -r line; do\n    line=\"${line%$'\\r'}\"\n    [ -z \"$line\" ] && break\n    case \"$line\" in\n      [Cc]ontent-[Ll]ength:*) content_length=\"${line#*: }\" ;;\n    esac\n  done\n\n  body=\"\"\n  [ \"$content_length\" -gt 0 ] && body=$(head -c \"$content_length\")\n\n  event=$(printf '%s' \"$body\" | jq -r '.event // empty' 2>/dev/null || true)\n  session=$(printf '%s' \"$body\" | jq -r '.call.sessionId // empty' 2>/dev/null || true)\n  echo \"event=$event sessionId=$session\" >&2\n\n  if [ \"$event\" = \"call.incoming\" ]; then\n    response=$(jq -n --arg sinch \"$SINCH_NUMBER\" --arg dest \"$DESTINATION_NUMBER\" '{\n\t  callName: \"incoming\",\n      commands: [\n        { command: \"answer\" },\n        { command: \"messages\", messagesName: \"greeting\",\n          messages: [\n            { type: \"SAY\",\n              say: { text: \"Welcome to Acme. Please hold while we connect your call.\",\n                     voiceName: \"Emma\" } }\n          ] },\n        { command: \"bridgeCall\", bridgeName: \"inbound-bridge\" },\n        { command: \"dial\", callName: \"agent\",\n          from: { type: \"PHONE\", phone: { number: $sinch } },\n          to:   { type: \"PHONE\", phone: { number: $dest } },\n          dialTimeoutDurationSeconds: 30,\n          events: {\n            onAnswer:  [ { command: \"bridgeCall\", bridgeName: \"inbound-bridge\" } ],\n            onHangup:  [ { command: \"hangup\", callName: \"incoming\" } ],\n            onTimeout: [ { command: \"hangup\", callName: \"incoming\" } ]\n          } }\n      ],\n      events: { onHangup: [ { command: \"hangup\", callName: \"agent\"} ] }\n    }')\n  else\n    response='{\"commands\":[]}'\n  fi\n\n  len=$(printf '%s' \"$response\" | wc -c | tr -d ' ')\n  printf 'HTTP/1.1 200 OK\\r\\nContent-Type: application/json\\r\\nContent-Length: %s\\r\\nConnection: close\\r\\n\\r\\n%s' \\\n    \"$len\" \"$response\"\n  exit 0\nfi\n\necho \"Inbound PSTN webhook server (bash/ncat) on :$PORT\" >&2\nexec ncat -lk \"$PORT\" --sh-exec \"bash $0 --handle\"\n","lang":"bash"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Run it:"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"bash","header":{"controls":{"copy":{}}},"source":"bash server.sh\n","lang":"bash"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Python"]}," (",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["server.py"]},")"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"python","header":{"controls":{"copy":{}}},"source":"# Sinch Voice API v2 - Inbound PSTN webhook server (Flask).\n# Handles call.incoming by greeting the caller, then dialing an agent and bridging.\n#\n# Requirements: pip install flask\n# Run:    python server.py   (with SINCH_NUMBER / DESTINATION_NUMBER exported)\n# Expose: ngrok http 3000\n\nimport os\nimport sys\n\nfrom flask import Flask, jsonify, request\n\nSINCH_NUMBER       = os.environ.get(\"SINCH_NUMBER\")\nDESTINATION_NUMBER = os.environ.get(\"DESTINATION_NUMBER\")\nPORT               = int(os.environ.get(\"PORT\", \"3000\"))\n\nif not SINCH_NUMBER or not DESTINATION_NUMBER:\n    print(\"ERROR: export SINCH_NUMBER and DESTINATION_NUMBER first.\", file=sys.stderr)\n    sys.exit(1)\n\napp = Flask(__name__)\n\n\n@app.post(\"/voice/events\")\ndef voice_events():\n    payload = request.get_json(silent=True) or {}\n    event = payload.get(\"event\")\n    call  = payload.get(\"call\") or {}\n    print(\n        f\"event={event} sessionId={call.get('sessionId')} \"\n        f\"from={(call.get('from') or {}).get('phone', {}).get('number')} \"\n        f\"to={(call.get('to')   or {}).get('phone', {}).get('number')}\"\n    )\n\n    if event == \"call.incoming\":\n        return jsonify({\n\t\t    \"callName\": \"incoming\",\n            \"commands\": [\n                {\"command\": \"answer\"},\n                {\n                    \"command\": \"messages\",\n                    \"messagesName\": \"greeting\",\n                    \"messages\": [\n                        {\"type\": \"SAY\",\n                         \"say\": {\"text\": \"Welcome to Acme. Please hold while we connect your call.\",\n                                 \"voiceName\": \"Emma\"}}\n                    ],\n                },\n                {\"command\": \"bridgeCall\", \"bridgeName\": \"inbound-bridge\"},\n                {\n                    \"command\": \"dial\",\n                    \"callName\": \"agent\",\n                    \"from\": {\"type\": \"PHONE\", \"phone\": {\"number\": SINCH_NUMBER}},\n                    \"to\":   {\"type\": \"PHONE\", \"phone\": {\"number\": DESTINATION_NUMBER}},\n                    \"dialTimeoutDurationSeconds\": 30,\n                    \"events\": {\n                        \"onAnswer\":  [{\"command\": \"bridgeCall\", \"bridgeName\": \"inbound-bridge\"}],\n                        \"onHangup\":  [{\"command\": \"hangup\", \"callName\": \"incoming\"}],\n                        \"onTimeout\": [{\"command\": \"hangup\", \"callName\": \"incoming\"}],\n                    },\n                },\n            ],\n            \"events\": {\"onHangup\": [{\"command\": \"hangup\", \"callName\": \"agent\"}]},\n        })\n\n    return jsonify({\"commands\": []})\n\n\nif __name__ == \"__main__\":\n    print(f\"Inbound PSTN webhook server on :{PORT}\")\n    app.run(host=\"0.0.0.0\", port=PORT)\n","lang":"python"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Run it:"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"bash","header":{"controls":{"copy":{}}},"source":"python server.py\n","lang":"bash"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Node.js"]}," (",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["server.js"]},")"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"javascript","header":{"controls":{"copy":{}}},"source":"// Sinch Voice API v2 - Inbound PSTN webhook server (Express).\n// Handles call.incoming by greeting the caller, then dialing an agent and bridging.\n//\n// Requirements: npm install express\n// Run:    node server.js   (with SINCH_NUMBER / DESTINATION_NUMBER exported)\n// Expose: ngrok http 3000\n\nimport express from \"express\";\n\nconst SINCH_NUMBER       = process.env.SINCH_NUMBER       || (() => { throw new Error(\"SINCH_NUMBER not set, export it first\"); })();\nconst DESTINATION_NUMBER = process.env.DESTINATION_NUMBER || (() => { throw new Error(\"DESTINATION_NUMBER not set, export it first\"); })();\nconst PORT = process.env.PORT || 3000;\n\nconst app = express();\napp.use(express.json());\n\napp.post(\"/voice/events\", (req, res) => {\n  const { event, call } = req.body || {};\n  console.log(`event=${event} sessionId=${call?.sessionId} from=${call?.from?.phone?.number} to=${call?.to?.phone?.number}`);\n\n  if (event === \"call.incoming\") {\n    return res.status(200).json({\n\t  callName: \"incoming\",\n      commands: [\n        { command: \"answer\" },\n        {\n          command: \"messages\",\n          messagesName: \"greeting\",\n          messages: [\n            {\n              type: \"SAY\",\n              say: { text: \"Welcome to Acme. Please hold while we connect your call.\", voiceName: \"Emma\" },\n            },\n          ],\n        },\n        { command: \"bridgeCall\", bridgeName: \"inbound-bridge\" },\n        {\n          command: \"dial\",\n          callName: \"agent\",\n          from: { type: \"PHONE\", phone: { number: SINCH_NUMBER } },\n          to:   { type: \"PHONE\", phone: { number: DESTINATION_NUMBER } },\n          dialTimeoutDurationSeconds: 30,\n          events: {\n            onAnswer: [{ command: \"bridgeCall\", bridgeName: \"inbound-bridge\" }],\n            onHangup: [{ command: \"hangup\", callName: \"incoming\" }],\n            onTimeout:[{ command: \"hangup\", callName: \"incoming\" }]\n          },\n        },\n      ],\n      events: { onHangup: [{ command: \"hangup\", callName: \"agent\" }] },\n    });\n  }\n\n  // For all other events, acknowledge with no commands.\n  return res.status(200).json({ commands: [] });\n});\n\napp.listen(PORT, () => {\n  console.log(`Inbound PSTN webhook server on :${PORT}`);\n  console.log(`Configure service callBehavior.webhook.url -> https://<your-ngrok>/voice/events`);\n});\n","lang":"javascript"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Run it:"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"bash","header":{"controls":{"copy":{}}},"source":"node server.js\n","lang":"bash"},"children":[]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":3,"id":"smoke-test-the-server-locally","__idx":6},"children":["Smoke-test the server locally"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Before exposing it, confirm it responds to a simulated event. You should get back the SVAML JSON and see a log line on the server."]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["bash"]}]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"bash","header":{"controls":{"copy":{}}},"source":"curl -s -X POST http://localhost:3000/voice/events \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"event\":\"call.incoming\",\"call\":{\"sessionId\":\"test\",\"from\":{\"phone\":{\"number\":\"+15551112222\"}},\"to\":{\"phone\":{\"number\":\"+14045001000\"}}}}'\n","lang":"bash"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Python"]}," (",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["smoke_test.py"]},")"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"python","header":{"controls":{"copy":{}}},"source":"import requests\n\nresp = requests.post(\n    \"http://localhost:3000/voice/events\",\n    json={\n        \"event\": \"call.incoming\",\n        \"call\": {\n            \"sessionId\": \"test\",\n            \"from\": {\"phone\": {\"number\": \"+15551112222\"}},\n            \"to\":   {\"phone\": {\"number\": \"+14045001000\"}},\n        },\n    },\n)\nprint(resp.status_code, resp.json())\n","lang":"python"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Node.js"]}," (",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["smoke-test.js"]},")"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"javascript","header":{"controls":{"copy":{}}},"source":"const resp = await fetch(\"http://localhost:3000/voice/events\", {\n  method: \"POST\",\n  headers: { \"Content-Type\": \"application/json\" },\n  body: JSON.stringify({\n    event: \"call.incoming\",\n    call: {\n      sessionId: \"test\",\n      from: { phone: { number: \"+15551112222\" } },\n      to:   { phone: { number: \"+14045001000\" } },\n    },\n  }),\n});\nconsole.log(resp.status, await resp.json());\n","lang":"javascript"},"children":[]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"step-2---expose-it-with-ngrok","__idx":7},"children":["Step 2 - Expose it with ngrok"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"bash","header":{"controls":{"copy":{}}},"source":"ngrok http 3000\n","lang":"bash"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Copy the ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["https://<id>.ngrok-free.app"]}," URL ngrok prints. Your webhook URL is that base ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["plus the ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["/voice/events"]}," path"]}," the server listens on. Export it for the next step:"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"bash","header":{"controls":{"copy":{}}},"source":"export WEBHOOK_URL=\"https://<id>.ngrok-free.app/voice/events\"\n","lang":"bash"},"children":[]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"step-3---point-the-service-webhook-at-your-server","__idx":8},"children":["Step 3 - Point the service webhook at your server"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["PATCH the service to set ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["callBehavior.type = WEBHOOK"]}," with your ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["full webhook URL including the path"]},":"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["bash"]}]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"bash","header":{"controls":{"copy":{}}},"source":"curl -s -X PATCH \\\n  -u \"$KEY_ID:$KEY_SECRET\" \\\n  \"https://voice.api.sinch.com/v2/projects/$PROJECT_ID/services/$SERVICE_ID\" \\\n  -H \"Content-Type: application/json\" \\\n  -d \"{\n    \\\"callBehavior\\\": {\n      \\\"type\\\": \\\"WEBHOOK\\\",\n      \\\"webhook\\\": {\n        \\\"url\\\":         \\\"$WEBHOOK_URL\\\",\n        \\\"fallbackUrl\\\": \\\"$WEBHOOK_URL\\\"\n      }\n    }\n  }\"\n","lang":"bash"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Python"]}," (",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["configure_service.py"]},")"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"python","header":{"controls":{"copy":{}}},"source":"import os\nimport requests\n\nwebhook_url = os.environ[\"WEBHOOK_URL\"]\n\nresp = requests.patch(\n    f\"https://voice.api.sinch.com/v2/projects/{os.environ['PROJECT_ID']}\"\n    f\"/services/{os.environ['SERVICE_ID']}\",\n    auth=(os.environ[\"KEY_ID\"], os.environ[\"KEY_SECRET\"]),\n    json={\n        \"callBehavior\": {\n            \"type\": \"WEBHOOK\",\n            \"webhook\": {\"url\": webhook_url, \"fallbackUrl\": webhook_url},\n        }\n    },\n)\nprint(resp.status_code, resp.json())\n","lang":"python"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Node.js"]}," (",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["configure-service.js"]},")"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"javascript","header":{"controls":{"copy":{}}},"source":"const webhookUrl = process.env.WEBHOOK_URL;\nconst auth = Buffer.from(`${process.env.KEY_ID}:${process.env.KEY_SECRET}`).toString(\"base64\");\n\nconst resp = await fetch(\n  `https://voice.api.sinch.com/v2/projects/${process.env.PROJECT_ID}/services/${process.env.SERVICE_ID}`,\n  {\n    method: \"PATCH\",\n    headers: { Authorization: `Basic ${auth}`, \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({\n      callBehavior: {\n        type: \"WEBHOOK\",\n        webhook: { url: webhookUrl, fallbackUrl: webhookUrl },\n      },\n    }),\n  }\n);\nconsole.log(resp.status, await resp.json());\n","lang":"javascript"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["fallbackUrl"]}," is optional but recommended: once the primary ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["url"]}," starts returning errors, Sinch switches ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["future"]}," requests to the fallback (it does not retry the ",{"$$mdtype":"Tag","name":"em","attributes":{},"children":["same"]}," failed request against it)."]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"step-4---dial-your-sinch-number","__idx":9},"children":["Step 4 - Dial your Sinch number"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Call ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["SINCH_NUMBER"]}," from any phone."]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":3,"id":"what-success-looks-like","__idx":10},"children":["What success looks like"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Your terminal logs the incoming event, e.g.:"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"header":{"controls":{"copy":{}}},"source":"event=call.incoming sessionId=01AN4Z07BY79KA1307SR9X4MV2 from=+14155552671 to=+14045001000\n"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["You should hear \"Welcome to Acme. Please hold while we connect your call,\" then ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["DESTINATION_NUMBER"]}," rings. Answer it and the two legs are bridged. If the agent doesn't answer within 30 seconds the call hangs up."]},{"$$mdtype":"Tag","name":"hr","attributes":{},"children":[]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"reference-the-webhook-request","__idx":11},"children":["Reference: the webhook request"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Sinch delivers webhooks in ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["CloudEvents 1.0, HTTP binary content mode"]},". CloudEvents metadata travels as ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["ce-*"]}," headers; the JSON body carries the event:"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"http","header":{"controls":{"copy":{}}},"source":"POST /voice/events\nce-specversion: 1.0\nce-type:        com.sinch.voice.call.control.v1\nce-source:      projects/<projectId>/services/<serviceId>\nce-id:          <uuid>\nce-time:        2026-04-01T12:00:00+00:00\ncontent-type:   application/json\n\n{\n  \"event\": \"call.incoming\",\n  \"call\": {\n    \"callId\":          \"01AN4Z07BY79KA1307SR9X4MV3\",\n    \"projectId\":       \"...\",\n    \"serviceId\":       \"...\",\n    \"sessionId\":       \"01AN4Z07BY79KA1307SR9X4MV2\",\n    \"direction\":       \"INBOUND\",\n    \"originationType\": \"PHONE\",\n    \"callType\":        \"PHONE\",\n    \"from\":   { \"type\": \"PHONE\", \"phone\": { \"number\": \"+14155552671\" } },\n    \"to\":     { \"type\": \"PHONE\", \"phone\": { \"number\": \"+46735224800\" } },\n    \"callResult\": \"INITIATED\",\n    \"startTime\":  \"2025-06-01T10:00:00Z\"\n  }\n}\n","lang":"http"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["The body has two top-level fields: ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["event"]}," (the event name) and ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["call"]}," (the call state). ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["call.from.phone.number"]}," is the calling party (the customer); ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["call.to.phone.number"]}," is the Sinch number they dialed. Use ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["to"]}," to route; most deployments have several Sinch numbers, one per persona or region."]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":3,"id":"ce-id-for-deduplication","__idx":12},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["ce-id"]}," for deduplication"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["ce-id"]}," is a unique per-event UUID; combined with ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["ce-source"]}," it identifies a single delivery. The example servers don't validate headers, but production code should (see the checklist)."]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Things to know:"]},{"$$mdtype":"Tag","name":"ul","attributes":{},"children":[{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["commands"]}," is a normal SVAML sequence, the same primitives as any outbound flow."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":["The inbound leg is answered explicitly with ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["answer"]},"; ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["bridgeCall"]}," creates the named bridge on first use and the agent leg joins the same bridge in its ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["onAnswer"]},"."]}]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"reference-subsequent-events","__idx":13},"children":["Reference: subsequent events"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["After ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["call.incoming"]}," you'll typically receive (for the outbound agent leg you dialed):"]},{"$$mdtype":"Tag","name":"ul","attributes":{},"children":[{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["call.answered"]},": the agent leg was answered."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["call.busy"]}," / ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["call.rejected"]}," / ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["call.timeout"]}," / ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["call.failed"]},": the leg ended without being answered."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["call.hangup"]},": the call terminated."]}]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Inline events suppress the webhook."]}," If a ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["dial"]}," (or other) command defines an ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["events"]}," block, the platform runs that block and ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["no webhook fires"]}," for those lifecycle events. If you omit ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["events"]}," entirely, you'll get the webhook instead. An explicit empty ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["events: {}"]}," ",{"$$mdtype":"Tag","name":"em","attributes":{},"children":["suppresses"]}," the webhook without running anything, useful to ignore a leg's lifecycle. Because the example ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["dial"]}," defines ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["events"]}," inline, you won't see ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["call.answered"]},"/",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["call.timeout"]}," webhooks for the agent leg."]},{"$$mdtype":"Tag","name":"hr","attributes":{},"children":[]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"common-patterns","__idx":14},"children":["Common patterns"]},{"$$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":"Want to..."},"children":["Want to..."]},{"$$mdtype":"Tag","name":"th","attributes":{"data-label":"Drop-in change in the response commands"},"children":["Drop-in change in the response ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["commands"]}]}]}]},{"$$mdtype":"Tag","name":"tbody","attributes":{},"children":[{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":["Just play a message and hang up"]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":["A ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["messages"]}," block with ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["events.onFinish: [{ \"command\": \"hangup\" }]"]},"."]}]},{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":["Read DTMF and route"]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":["Use the ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["webhook"]}," SVAML command to call back into your backend after a prompt, then return a new sequence."]}]},{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":["Record the call"]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":["Insert ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["startRecording"]}," after the bridge; see ",{"$$mdtype":"Tag","name":"MarkdownLink","attributes":{"href":"/docs/voice-2.0/tutorials/recording-and-transcription"},"children":["Recording & Transcription"]},"."]}]},{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":["Detect voicemail before bridging"]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":["Insert ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["amd"]},"; see ",{"$$mdtype":"Tag","name":"MarkdownLink","attributes":{"href":"/docs/voice-2.0/tutorials/amd"},"children":["AMD"]},"."]}]},{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":["Send to an AI agent"]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":["Replace the agent ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["dial"]}," ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["to"]}," with ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["{ \"type\": \"VOICE_RELAY\", ... }"]},"; see ",{"$$mdtype":"Tag","name":"MarkdownLink","attributes":{"href":"/docs/voice-2.0/tutorials/voice-relay"},"children":["Voice Relay"]},"."]}]},{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":["Stream audio to a custom WS server"]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":["Use a ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["dial"]}," with ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["to: { \"type\": \"STREAM\", ... }"]},"; see ",{"$$mdtype":"Tag","name":"MarkdownLink","attributes":{"href":"/docs/voice-2.0/tutorials/stream-audio"},"children":["Stream Audio"]},"."]}]},{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":["Route a call to another number"]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":["See ",{"$$mdtype":"Tag","name":"MarkdownLink","attributes":{"href":"/docs/voice-2.0/tutorials/call-forwarding"},"children":["Call Forwarding"]},"."]}]}]}]}]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"production-readiness-checklist","__idx":15},"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":["Latency"]}]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":["Sinch enforces a ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["5-second response timeout"]}," per webhook. Cache routing rules in memory and respond fast."]}]},{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Delivery semantics"]}]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":["See the note below; the spec is inconsistent on whether webhooks retry. Treat your handler as needing to be both fast (in case of no retry) and idempotent (in case of retry)."]}]},{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Header validation"]}]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":["Check the CloudEvents headers (",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["ce-type"]}," = ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["com.sinch.voice.call.control.v1"]},") and verify the request signature in the ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["Authorization"]}," header against your service key/secret before acting."]}]},{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Persist critical state"]}]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":["Persist anything you need from the ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["call.incoming"]}," payload (e.g. for analytics or routing decisions) before responding."]}]},{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Fallback URL"]}]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":["Configure ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["fallbackUrl"]}," so a transient outage of your primary endpoint doesn't drop future calls."]}]},{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Static behavior for simple cases"]}]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":["If the SVAML is fixed (always the same), switch the service to ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["STATIC"]}," and embed the commands in ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["callBehavior.static"]},"; no server needed."]}]},{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Number routing"]}]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":["When several numbers point at one service, use ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["call.to.phone.number"]}," to look up the routing target."]}]},{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Real HTTP stack"]}]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":["The bash/ncat server is for local demos only; deploy the Python or Node.js version (or any real HTTP framework) behind TLS in production."]}]},{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Secrets management"]}]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["export"]},"-ing credentials is fine for local development; in production, inject them via your platform's secret manager or environment configuration instead of shell profiles."]}]}]}]}]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"what-the-openapi-spec-says---at-a-glance","__idx":16},"children":["What the OpenAPI spec says - at a glance"]},{"$$mdtype":"Tag","name":"ul","attributes":{},"children":[{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["PATCH /v2/projects/{projectId}/services/{serviceId}"]}," (",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["updateService"]},") sets ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["callBehavior"]},", one of ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["NONE"]},", ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["WEBHOOK"]},", ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["STATIC"]},"."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["POST /v2/projects/{projectId}/svaml/validate"]}," validates a SVAML payload (",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["{ \"svaml\": {\"commands\":[ ...commands ]} }"]},") and returns ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["{ \"isValid\", \"errors\" }"]},"."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":["The incoming webhook is delivered with CloudEvents ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["ce-*"]}," headers plus a JSON body containing ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["event"]}," + ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["call"]}," (",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["webhookRequest"]},")."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":["The response (",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["webhookResponse"]},") is ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["{ \"commands\": [...], \"callName\"?, \"events\"?: { \"onHangup\": [...] } }"]},". Commands run directly, no wrapper command. ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["callName"]}," and ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["events"]}," are honored only in responses to ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["call.incoming"]},"."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":["Authentication is HTTP Basic with your ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["KEY_ID"]}," / ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["KEY_SECRET"]},"."]}]}]},"headings":[{"value":"Handle Inbound PSTN Calls","id":"handle-inbound-pstn-calls","depth":1},{"value":"Overview","id":"overview","depth":2},{"value":"What you'll build","id":"what-youll-build","depth":2},{"value":"Setup","id":"setup","depth":2},{"value":"Step 0 - Smoke-test your SVAML (no phone needed)","id":"step-0---smoke-test-your-svaml-no-phone-needed","depth":2},{"value":"Step 1 - Write and start the webhook server","id":"step-1---write-and-start-the-webhook-server","depth":2},{"value":"Smoke-test the server locally","id":"smoke-test-the-server-locally","depth":3},{"value":"Step 2 - Expose it with ngrok","id":"step-2---expose-it-with-ngrok","depth":2},{"value":"Step 3 - Point the service webhook at your server","id":"step-3---point-the-service-webhook-at-your-server","depth":2},{"value":"Step 4 - Dial your Sinch number","id":"step-4---dial-your-sinch-number","depth":2},{"value":"What success looks like","id":"what-success-looks-like","depth":3},{"value":"Reference: the webhook request","id":"reference-the-webhook-request","depth":2},{"value":"ce-id for deduplication","id":"ce-id-for-deduplication","depth":3},{"value":"Reference: subsequent events","id":"reference-subsequent-events","depth":2},{"value":"Common patterns","id":"common-patterns","depth":2},{"value":"Production-readiness checklist","id":"production-readiness-checklist","depth":2},{"value":"What the OpenAPI spec says - at a glance","id":"what-the-openapi-spec-says---at-a-glance","depth":2}],"frontmatter":{"seo":{"title":"Handle Inbound PSTN Calls"}},"lastModified":"2026-10-23T15:54:27.000Z","pagePropGetterError":{"message":"","name":""}},"slug":"/docs/voice-2.0/tutorials/inbound-pstn","userData":{"isAuthenticated":false,"teams":["anonymous"]},"isPublic":true}