Table of Contents
- Overview
- Domain verification and sender authentication
- Rate limits
- Size and resource limits
- Event Grid integration
- A note on cross-cloud use
- SendGrid as an alternative
- Economics: SendGrid vs Azure
Overview
Azure Communication Services (ACS) Email is Microsoft's managed transactional email API. Two resource types are involved: a Communication Service resource (the parent) and an Email Communication Service resource (the domain and sending configuration). Sending happens through a REST endpoint or SDK, authenticated with a per-resource access key or Azure AD credential.
Two domain types are available, and the choice determines throughput:
- Azure-managed subdomain (
<random>.azurecomm.net): no DNS setup required, but throughput is capped and cannot be increased. - Custom verified domain: requires SPF and DKIM records on your own domain (DMARC is recommended but not required for verification), higher default throughput, and eligible for quota increases.
Domain verification and sender authentication
Setting up a custom domain is a two-step process in the Azure portal (or the equivalent Azure CLI, PowerShell, or SDK calls): verify ownership of the domain, then configure sender authentication. Azure-managed domains skip both steps entirely, which is why they're faster to set up and also why their throughput is fixed and non-negotiable.
Step 1: verify domain ownership
Adding a custom domain under the Email Communication Service resource's Provision Domains page generates one TXT record:
| Record | Type | Name | Value |
|---|---|---|---|
| Domain verification | TXT | domain root, or the exact name shown in the portal | ms-domain-verification=<unique-code> |
This proves control over the domain's DNS, nothing else. Add it at the registrar, then trigger verification in the portal. DNS propagation takes 15 to 30 minutes before the check passes.
Step 2: configure sender authentication (SPF and DKIM)
Once ownership is verified, the portal generates three more records. The exact host names depend on whether the domain being configured is the zone root or a subdomain within a larger zone, the portal assumes the record is added directly at the domain name entered, adding it to a parent zone instead shifts the host portion of each record accordingly.
Example values for a domain configured at the zone root:
Type Name Value
TXT @ v=spf1 include:spf.protection.outlook.com -all
CNAME selector1-azurecomm-prod-net._domainkey selector1-azurecomm-prod-net._domainkey.azurecomm.net
CNAME selector2-azurecomm-prod-net._domainkey selector2-azurecomm-prod-net._domainkey.azurecomm.net
What each record is for:
- SPF (Sender Policy Framework), a TXT record listing which mail servers are authorized to send as this domain. Receiving servers check the sending server's IP against this list. The
-allsuffix, rather than~all, tells receivers to hard-fail anything not on the list instead of accepting it with a lower trust score. A~allrecord is a common cause of SPF showing as unverified in the Azure portal even when the record resolves correctly, Azure's own guidance is to always use-all. - DKIM (DomainKeys Identified Mail), cryptographically signs each outgoing message with a private key; the CNAME points receivers at the corresponding public key so they can verify the signature wasn't altered in transit. Two separate selectors, DKIM and DKIM2, exist so the signing key can be rotated by switching which selector is active, without a window where outgoing mail is unsigned.
- DMARC, not required by Azure to complete verification, SPF and DKIM alone satisfy the portal, but strongly recommended for deliverability in practice. It's a separate TXT record at
_dmarc.<domain>specifying what receiving servers should do when SPF or DKIM fails (quarantine, reject, or take no action) and where to send aggregate failure reports. Without it, a spoofed message that fails SPF and DKIM is handled at each receiving server's own discretion rather than the domain owner's stated policy.
Propagation for the SPF and DKIM records again takes 15 to 30 minutes. The domain moves to Verified once both checks pass, and only then can it send mail or emit Event Grid delivery events.
Rate limits
| Operation | Azure-managed domain | Custom domain |
|---|---|---|
| Send email | 5/min, 10/hour | 30/min, 100/hour |
| Get email status | 10/min, 20/hour | 60/min, 200/hour |
| Quota increase available | No | Yes, up to 1-2 million/hour |
Quota increases on custom domains require a support request, are free, and are evaluated against sender reputation. The failure rate must stay under 1% to qualify, and requests take up to 72 hours to review. If the failure rate rises after an increase is granted, Microsoft will contact the account, and can reduce or suspend the resource if the issue isn't resolved.
Size and resource limits
| Name | Limit |
|---|---|
| Recipients per email (To+CC+BCC) | 50 |
| Total request size, including attachments | 10 MB |
| Attachments via support request | up to 30 MB |
| Domains per Communication Service resource | 100 |
| Sender addresses per domain | 100 |
| Max authenticated connections per subscription | 250 |
Base64 encoding adds roughly 33% to attachment size. A 10 MB request limit means roughly 7.5 MB of actual file content before encoding. For attachments larger than the support-request ceiling, the documented approach is to store the file in Blob Storage and link to it with a SAS token instead of attaching it directly.
Throttled requests return HTTP 429 with a Retry-After header. The client is expected to back off for that duration before retrying.
Event Grid integration
Azure Communication Services Email does not provide a built-in delivery dashboard comparable to dedicated ESPs. Instead, delivery and engagement data is exposed as events through Azure Event Grid, Azure's native pub-sub eventing service.
Event Grid's general model: an Azure resource (the "topic") publishes events when something happens. Subscribers register for specific event types and receive them via a push (webhook, Azure Function, Logic App, Service Bus, Event Hub, Storage Queue) or pull model. The publisher and subscriber are decoupled, the resource emitting events has no knowledge of what, if anything, is listening.
For Email specifically, the relevant event types are:
Microsoft.Communication.EmailDeliveryReportReceived: fired once a send attempt reaches a terminal state (delivered, bounced, quarantined, failed, or filtered as spam).Microsoft.Communication.EmailEngagementTrackingReportReceived: fired when a recipient opens the email or clicks a tracked link, if engagement tracking is enabled on the domain.
To use these, an Event Grid subscription is created against the Communication Service resource, filtered to the event types needed, and pointed at a handler. A common pattern is an Azure Function that receives the event, extracts the message ID and status, and writes it to a database or forwards it to a monitoring system, since ACS itself only retains this data briefly and doesn't expose a query API for historical delivery status beyond what Event Grid delivers at the time.
This is also the mechanism behind sender reputation management: repeated bounce or complaint events for a domain should feed into a suppression list (Azure Communication Services supports managed suppression lists) so the same address isn't retried, which is one of the levers that keeps the failure rate under the 1% threshold required for quota increases.
A note on cross-cloud use
A multi-tenant application, where each tenant has its own Communication Service and Email Communication Service pair for sending isolation (no shared reputation between tenants), works the same way regardless of where the application itself is hosted. If the application's compute has since moved to a different cloud provider, every email send becomes a cross-cloud API call: application to Azure, authenticated with that tenant's access key, with delivery status arriving asynchronously through Event Grid rather than in the send response itself.
This is a workable pattern, but it's an argument for eventually consolidating on a provider-agnostic or same-cloud email service: one fewer authenticated cross-cloud dependency, one less place a regional outage or credential rotation can affect, and one less set of per-tenant rate limits to track separately from the rest of the infrastructure.
SendGrid as an alternative
SendGrid (a Twilio product) is a dedicated email API and one of the more common replacements considered for this kind of migration. The parts relevant to a multi-tenant, customer-owned-domain setup:
- Subusers: a single paid account can hold multiple subusers, each with its own sending domain, API key, and statistics, billed under one parent subscription rather than one account per tenant. This is the direct analog to a separate Communication Service resource per tenant in Azure, but with per-subuser analytics and suppression management built into the dashboard rather than assembled from Event Grid events.
- Dedicated IP pools: subusers can be assigned to separate IP pools, so one tenant's bounce rate or spam complaints don't affect another's sending reputation. This is available starting on the Pro plan; the Essentials plan shares IP space across all subusers on the account.
- Domain authentication: the same SPF and DKIM DNS delegation pattern as Azure, a customer adds TXT and CNAME records to their own domain, verified from SendGrid's dashboard or API rather than the Azure portal.
- Event webhooks: SendGrid's equivalent of Event Grid delivery events, a single HTTPS endpoint receives delivery, bounce, open, and click events for all subusers, rather than a separate Event Grid subscription per resource.
Economics: SendGrid vs Azure
Azure Communication Services Email is pure pay-as-you-go with no monthly minimum: $0.00025 per email sent, plus $0.00012 per MB of data transferred (headers, body, and attachments combined). SendGrid instead bundles a flat monthly subscription with an included volume, then charges overage past that: Essentials at roughly $20/month for tens of thousands of included emails with about $1.00 per 1,000 emails overage, Pro at roughly $90/month for 100,000 included emails with about $0.85 per 1,000 overage. Exact included-volume figures vary slightly by source at the time of writing, the per-email overage rates are the more consistent number across pricing pages.
Working through a typical transactional email (a booking confirmation or similar, assume 50 KB average size) at three volumes:
| Volume/month | Azure ECS | SendGrid Pro ($89.95 base, 100k included, $0.85/1k overage) |
|---|---|---|
| 10,000 | ~$2.56 | $89.95 (flat, well under included volume) |
| 100,000 | ~$25.60 | $89.95 (flat, at the included volume) |
| 1,000,000 | ~$256 | ~$854.95 ($89.95 + 900k × $0.85/1k) |
What actual usage looks like
Hypothetical volumes are one thing, actual metered usage is more convincing. Here's the exact sequence used to pull it, so it can be checked independently.
1. Find every Communication Service resource in the subscription.
az resource list --resource-type "Microsoft.Communication/CommunicationServices" \
--query "[].{name:name, resourceGroup:resourceGroup, location:location}" -o table
Lists every resource of that type across the subscription, regardless of which resource group it sits in. This is how the full set of tenant resource names gets discovered in the first place, rather than assuming a naming convention.
2. Resolve one resource's full ARM ID.
az resource show --resource-group <resource-group> --name <tenant>-commsvc \
--resource-type "Microsoft.Communication/CommunicationServices" --query id -o tsv
az monitor metrics list needs a full ARM resource ID (/subscriptions/.../resourceGroups/.../providers/...), not just a name. This resolves it for one resource; the ID is otherwise predictable once the subscription ID and resource group are known, so this step only needs running once, not per tenant.
3. Discover which metrics the resource actually emits.
az monitor metrics list-definitions --resource "$RESOURCE_ID" \
--query "[].{name:name.value, unit:unit}" -o table
Communication Service resources emit metrics for every sub-service they offer (chat, SMS, calling, rooms, etc.), not just email. This lists all of them so the right metric name can be picked instead of guessed. DeliveryStatusUpdate is the one that corresponds to email send outcomes.
4. Confirm the metric is the right one by inspecting its dimensions.
az monitor metrics list-definitions --resource "$RESOURCE_ID" \
--query "[?name.value=='DeliveryStatusUpdate'].dimensions" -o json
Dimensions are the fields a metric can be broken down by. DeliveryStatusUpdate exposes MessageStatus, SenderDomain, IsHardBounce, and SmtpStatusCode, which confirms it's specifically about email delivery outcomes, not some other counter that happens to share a similar name.
5. Pull the 30-day count for one resource.
az monitor metrics list --resource "$RESOURCE_ID" --metric "DeliveryStatusUpdate" \
--start-time $(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%SZ) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \
--interval P1D --aggregation Total -o json \
| jq '[.value[0].timeseries[].data[].total // 0] | add // 0'
--interval P1D requests one data point per day rather than a single total for the whole 30-day span (a single-bucket interval covering the whole range returned inconsistent results in practice), so the jq filter sums each day's total into one number. // 0 handles days with no data, where the field would otherwise be null and break the sum.
6. Repeat step 5 for every tenant resource.
for name in tenant1-commsvc tenant2-commsvc tenant3-commsvc ... ; do
RESOURCE_ID="/subscriptions/<subscription-id>/resourceGroups/<resource-group>/providers/Microsoft.Communication/communicationServices/$name"
TOTAL=$(az monitor metrics list --resource "$RESOURCE_ID" --metric "DeliveryStatusUpdate" \
--start-time $(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%SZ) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \
--interval P1D --aggregation Total -o json 2>/dev/null \
| jq '[.value[0].timeseries[].data[].total // 0] | add // 0')
echo -e "$name\t$TOTAL"
done
Same query as step 5, looped over every resource name found in step 1. The resource ID is assembled directly rather than re-running step 2 for each one, since only the resource name changes.
The real 30-day totals for one production environment came out to:
| Tenant | Emails (30d) |
|---|---|
| Tenant A | 51,463 |
| Tenant B | 3,070 |
| Tenant C | 2,549 |
| Tenant D | 616 |
| 6 other tenants | 0 (inactive or not yet sending) |
| Total | ~57,700 |
One tenant accounts for roughly 89% of all volume, the rest is a long tail, several tenants sending nothing at all in the window. At this real total, Azure's actual monthly cost is:
57,700 emails x $0.00025 = $14.43
57,700 x 50 KB (0.05 MB) x $0.00012 = $0.35
Total ~$14.78/month
Against that same ~57,700/month, SendGrid's Pro plan (flat $89.95, well under its 100k included volume) would cost roughly 6x more than what Azure is actually billing today. This is the concrete version of the conclusion above: at the volume this platform currently sends, cost alone gives no reason to move off Azure. Any migration case has to be made entirely on the tooling and reputation-isolation gap, not the invoice.
Azure is cheaper on raw unit economics at every volume in this range, by roughly 3 to 35 times depending on how far under SendGrid's included allotment the actual volume falls. This is expected: Azure has no platform fee at all, its price is pure marginal cost, while SendGrid's subscription bundles a flat cost for its dashboard, subuser isolation, dedicated IP pools, and support, features Azure either doesn't offer or requires building yourself on top of Event Grid.
The conclusion this leads to: if the migration off Azure is being justified on cost, the numbers don't support it at moderate volumes. The actual case for SendGrid or a similar provider is the tooling gap, per-tenant reputation isolation, deliverability analytics, and suppression management as first-class dashboard features, not a cheaper invoice. Whether that tooling is worth the premium depends on how much operational time is currently spent building the equivalent on top of Azure's Event Grid events, and how many tenant domains exist to manage reputation for individually.