Most small businesses solve “we need a phone number with voicemail” by paying a flat monthly fee to a service like Google Voice or OpenPhone. That’s a perfectly reasonable choice — but if you’d rather own the code, pay only for what you actually use, and have full control over the call flow, you can build the same thing yourself on Azure Communication Services (ACS) for a few dollars a month at low volume.
This is a walkthrough of exactly how: a virtual phone number that answers calls, plays a greeting, records a message, transcribes it, and emails you both the transcript and the audio — all as a serverless Azure Function with no server to maintain.
Architecture at a glance
Caller → ACS phone number → Event Grid (IncomingCall event) │
Azure Function: answers the call,
checks a spam/rate-limit gate
│
Mid-call events (DTMF gather, play
prompts, start recording) handled by
a webhook callback
│
Recording finishes → Event Grid (RecordingFileStatusUpdated)
│
Azure Function: downloads the
recording, transcribes it, emails
the transcript + audio attachment
Everything runs on Azure Functions (serverless, scales to zero), so idle cost is close to nothing — you pay per-minute for calls and per-email, not a flat subscription.
Stack:
- Azure Communication Services (ACS) — the phone number and call handling (Call Automation API)
- Azure Functions (isolated worker, Flex Consumption plan) — the application logic
- Azure AI Speech (multi-service Cognitive Services resource) — in-call text-to-speech and post-call transcription
- Azure Table Storage — a few small tables for spam blocking, rate limiting, and a monthly budget cap
- ACS Email — sends the final voicemail-with-transcript email
Prerequisites
- A Pay-As-You-Go Azure subscription — phone number purchases specifically require this; a free-trial subscription won’t work.
- The Azure CLI installed and logged in (
az login). - .NET 10 SDK if you’re writing the app in C#. Worth being precise about the versioning here: the Azure Functions host runtime is versioned separately (currently 4.x) from the .NET isolated worker version your code targets (10, in this case) —
--runtime-version 10in the commands below refers to the latter, not the Functions runtime itself.
Step 1: Create a resource group
az group create --name my-voicemail-rg --location eastus
Step 2: Create the Communication Services resource and buy a number
az communication create \ --name MyVoicemailContact \ --resource-group my-voicemail-rg \ --location global \ --data-location "United States"
Buying a phone number itself is easiest through the Azure Portal: open the resource, go to Phone Numbers → Get, pick a number with Voice (PSTN) capability, and complete the purchase. It’s a real recurring charge (typically under $2/month), so this is one step worth doing manually with eyes on the price before confirming.
Step 3: Create a Storage account
Used for a handful of small tables: a spam blocklist, a rolling call-attempt counter for rate limiting, a monthly budget tracker, and a table that bridges caller ID across the call’s event lifecycle.
az storage account create \ --name myvoicemailstg \ --resource-group my-voicemail-rg \ --location eastus \ --sku Standard_LRS
The tables themselves don’t need to be created up front — the application creates them automatically on first run.
Step 4: Create an Azure AI Speech resource
This needs to be the multi-service Cognitive Services kind (--kind CognitiveServices), not a standalone Speech-only resource. This isn’t a soft recommendation — Microsoft’s own integration docs state it plainly: “This integration only supports Multi-service Cognitive Service resource… confirm that it’s a Multi-service Cognitive Service resource.” A standalone Speech resource will fail the Call Automation authentication handshake outright.
az cognitiveservices account create \ --name my-voicemail-speech \ --resource-group my-voicemail-rg \ --location eastus \ --kind CognitiveServices \ --sku S0 \ --custom-domain my-voicemail-speech
The --custom-domain flag matters: it gives the resource an endpoint in the form https://<name>.cognitiveservices.azure.com/, which is required for the identity-based authentication in the next step (rather than the generic regional endpoint).
Step 5: Connect ACS to the Speech resource
Call Automation authenticates to Cognitive Services using a system-assigned managed identity on the ACS resource — no API keys involved for this particular integration. Note this identity only covers the in-call TTS and DTMF recognition that ACS itself performs mid-call; the post-call transcription step (your function’s own code calling the Speech resource’s transcription API after the recording finishes) is a separate integration that authenticates with an API key instead — see AZURE_SPEECH_KEY in Step 9. Both are needed; they’re not two ways of doing the same thing.
# Enable the identityaz communication identity assign --system-assigned \ --name MyVoicemailContact \ --resource-group my-voicemail-rg# Grant it access to the Speech resourceSPEECH_ID=$(az cognitiveservices account show \ --name my-voicemail-speech --resource-group my-voicemail-rg \ --query id -o tsv)PRINCIPAL_ID=$(az communication show \ --name MyVoicemailContact --resource-group my-voicemail-rg \ --query identity.principalId -o tsv)az role assignment create \ --assignee "$PRINCIPAL_ID" \ --role "Cognitive Services User" \ --scope "$SPEECH_ID"
Then, in the Azure Portal, open the ACS resource → Cognitive Services tab → Connect cognitive service, and select the Speech resource you just created. This linking step (distinct from the role assignment above) is what actually registers the connection.
Step 6: Set up Email
az communication email create \ --name my-voicemail-mail \ --resource-group my-voicemail-rg \ --location global \ --data-location "United States"az communication email domain create \ --domain-name AzureManagedDomain \ --email-service-name my-voicemail-mail \ --resource-group my-voicemail-rg \ --location global \ --domain-management AzureManaged
An Azure-managed domain gives you a working, pre-verified sender address immediately (something like DoNotReply@<guid>.azurecomm.net) — no DNS records to configure. You can optionally add a custom sender username with a friendlier display name via az communication email domain sender-username create.
Finally, link the domain to your ACS resource so it can send from it:
DOMAIN_ID=$(az communication email domain show \ --domain-name AzureManagedDomain \ --email-service-name my-voicemail-mail \ --resource-group my-voicemail-rg \ --query id -o tsv)az communication update \ --name MyVoicemailContact \ --resource-group my-voicemail-rg \ --linked-domains "$DOMAIN_ID"
Step 7: Create the Function App
az functionapp create \ --name my-voicemail-func \ --resource-group my-voicemail-rg \ --storage-account myvoicemailstg \ --flexconsumption-location eastus \ --runtime dotnet-isolated \ --runtime-version 10 \ --instance-memory 512
Step 8: The application logic
At a high level, the Function App needs:
- An Event Grid–triggered function for
Microsoft.Communication.IncomingCall— checks a budget kill-switch and spam gate (blocklist, rate limiter), then either rejects the call outright or answers it, passing a callback URL for mid-call events. - An HTTP-triggered function as that callback — handles the sequence of events for an answered call: optionally a “press 1” DTMF gate, then a spoken greeting, then start recording alongside a beep tone.
- An Event Grid–triggered function for
Microsoft.Communication.RecordingFileStatusUpdated— downloads the finished recording immediately, sends it to the Speech resource’s transcription API, and emails the transcript with the audio attached. - A timer-triggered function to reset the monthly budget counter on the 1st of each month.
A few implementation details worth building in from the start rather than retrofitting later:
- Start the recording and play the beep as two separate steps with a short delay in between, rather than firing both at the exact same instant — the recording pipeline has its own brief startup latency, and starting it a beat before the beep plays avoids clipping the very start of what the caller says.
- Keep prompt/greeting text and the routing mode (voicemail vs. forward-to-a-number) as app settings, not hardcoded strings — lets you change wording or behavior without a redeploy.
- Make the “press 1” DTMF gate optional, via something like a
VOICEMAIL_REQUIRE_DTMFsetting. It’s a genuinely effective way to filter out robocallers (they essentially never respond to a DTMF prompt), but it does add a small amount of friction for real callers — worth being able to toggle depending on how much spam you’re actually seeing rather than always making callers do it. - Treat the recording’s
contentLocationURL as temporary, not durable storage. ACS recording files are only available for a limited window (currently 24 hours) after the recording completes — download it immediately when the event fires, don’t defer it. If you want the audio to persist beyond the email attachment (for compliance, or just peace of mind), copy it to Blob Storage as part of the same function before or alongside sending the email; this guide’s design intentionally skips that to keep the whole thing storage-free and cost-free between calls, but it’s a reasonable thing to add if durable retention matters to you.
Step 9: Configure app settings
az functionapp config appsettings set \ --name my-voicemail-func \ --resource-group my-voicemail-rg \ --settings \ "ACS_CONNECTION_STRING=<from az communication list-key>" \ "APP_BASE_URL=https://<your-function-app-hostname>" \ "CALL_EVENTS_SECRET=<a random string>" \ "AZURE_SPEECH_KEY=<from az cognitiveservices account keys list>" \ "AZURE_SPEECH_REGION=eastus" \ "AZURE_COGNITIVE_SERVICES_ENDPOINT=https://my-voicemail-speech.cognitiveservices.azure.com/" \ "VOICEMAIL_REQUIRE_DTMF=false" \ "GATHER_PROMPT_TEXT=Please press 1 to leave a message." \ "GREETING_PROMPT_TEXT=Please leave your message after the beep." \ "SENDER_EMAIL=<your verified sender address>" \ "DEST_EMAIL=<where voicemails should be sent>" \ "ROUTE_MODE=voicemail" \ "MONTHLY_BUDGET_USD=10"
Two settings worth calling out:
AZURE_SPEECH_KEYandAZURE_COGNITIVE_SERVICES_ENDPOINTaren’t duplicating the managed identity from Step 5 — the endpoint is what ACS itself uses (via the managed identity) for in-call TTS/DTMF; the key is what your own function code uses to call the transcription API directly after the call ends. Different call paths, both required.CALL_EVENTS_SECRETprotects the mid-call webhook — it’s the one plain HTTP endpoint in the whole design, so appending a shared secret as a query parameter and checking it on every request keeps it from being callable by anyone who guesses the URL. This is the same pattern as a Function-level auth key, just applied explicitly since the endpoint itself is set to anonymous auth (required so Call Automation’s own callback can reach it without needing to know a Functions-specific key format).
Step 10: Wire up Event Grid
FUNC_KEY=$(az functionapp keys list \ --name my-voicemail-func --resource-group my-voicemail-rg \ --query "systemKeys.eventgrid_extension" -o tsv)FUNC_HOST=$(az functionapp show \ --name my-voicemail-func --resource-group my-voicemail-rg \ --query "defaultHostName" -o tsv)ACS_ID=$(az communication show \ --name MyVoicemailContact --resource-group my-voicemail-rg \ --query id -o tsv)az eventgrid event-subscription create \ --name incoming-call-sub \ --source-resource-id "$ACS_ID" \ --endpoint-type webhook \ --endpoint "https://${FUNC_HOST}/runtime/webhooks/eventgrid?functionName=IncomingCallHandler&code=${FUNC_KEY}" \ --included-event-types Microsoft.Communication.IncomingCall \ --event-delivery-schema cloudeventschemav1_0az eventgrid event-subscription create \ --name recording-complete-sub \ --source-resource-id "$ACS_ID" \ --endpoint-type webhook \ --endpoint "https://${FUNC_HOST}/runtime/webhooks/eventgrid?functionName=RecordingComplete&code=${FUNC_KEY}" \ --included-event-types Microsoft.Communication.RecordingFileStatusUpdated \ --event-delivery-schema cloudeventschemav1_0
Step 11: A budget safety net — two layers, not one
Because this is metered, per-minute billing rather than a flat fee, it’s worth capping it. Don’t rely on a single mechanism here, since the two available approaches have different strengths:
Layer 1 — an in-app kill-switch (the fast, primary gate). Track a running total for the current month in Table Storage, incrementing it right after each billable action (recording minutes, transcription minutes, emails sent) completes. Your IncomingCallHandler checks this total before answering each new call — over the cap, it rejects every subsequent call outright, no exceptions, until the month resets. This is an estimate reconciled from your own known unit costs, not Azure’s authoritative billing, but it reacts instantly, at the start of the very next call.
Layer 2 — an Azure Cost Management budget (a backstop, not an instant cutoff). This watches Azure’s actual billed spend and can call a webhook when a threshold is crossed. It’s valuable for catching drift between your in-app estimate and what Azure actually bills (rounding, minimum billing increments) — but real billing data lags actual usage by some hours, so treat it as a slower-reacting backstop behind Layer 1, not a real-time cutoff on its own:
az consumption budget create \ --budget-name my-voicemail-monthly-budget \ --amount 10 \ --category cost \ --resource-group my-voicemail-rg \ --time-grain monthly \ --start-date $(date -u +%Y-%m-01) \ --end-date $(date -u -v+10y +%Y-%m-01 2>/dev/null || date -u -d '+10 years' +%Y-%m-01)
(Attaching a notification action to the budget currently needs the underlying ARM REST API rather than this CLI command directly — az rest against Microsoft.Consumption/budgets with a notifications block referencing an Action Group.)
Step 12: Deploy and test
func azure functionapp publish my-voicemail-func
Call your new number. You should hear the “press 1” prompt, press 1, hear your greeting, then a beep — leave a message, hang up, and within a minute or two check your inbox for the transcript and audio attachment.
What this costs
Everything here is metered rather than flat-fee — you pay for call minutes, transcription minutes, emails sent, and the phone number’s own small monthly rental, nothing else. At the traffic level a small business generates, that typically lands in the low single-digit dollars per month, scaling roughly linearly with actual call volume rather than jumping between flat-rate tiers. Exact rates for calling, transcription, and number rental all vary by region and change over time, so check current pricing for your own region before budgeting precisely — the two-layer budget cap in Step 11 is there specifically so a guess that turns out wrong doesn’t turn into a surprise.