Migrate from Azure Communication Services Email to Groundmail

Microsoft is retiring Azure Communication Services (ACS) as a standalone offering, and Email is one of the retired services. Per Microsoft's retirement and breaking changes guide:

  • September 30, 2028: ACS Email (REST API, SDKs and SMTP) stops being available. After that date, API and SDK calls may return errors saying the operation isn't supported.
  • Until then, existing resources keep working. Your ACS resources, connection strings and verified domains stay available and supported through September 30, 2028, with security and critical fixes only.
  • From October 23, 2026, Microsoft begins limiting onboarding to retiring services. New ACS Email resources can currently still be created, but Microsoft says that can change at any time and recommends against starting new solutions on ACS Email.

Microsoft's own suggestions for email are Microsoft 365 High Volume Email, which only sends to recipients inside your Microsoft 365 tenant, and Exchange Online, which is built for person-to-person mail from licensed mailboxes. Neither is designed for sending password resets, receipts and alerts to your customers, so app email to external recipients needs a provider.

This guide moves ACS Email sending to Groundmail field by field: authentication, the REST and SDK send call, delivery status, domains, SMTP and suppressions, with before-and-after code in C#, Node.js and Python.


Keep keyless auth: managed identity

If your apps call ACS Email with a managed identity (DefaultAzureCredential and no connection string), you can keep it that way. Groundmail accepts Microsoft Entra access tokens from Azure managed identities and workload identities, so there's still no API key to store in Key Vault, rotate or audit.

What changes is the token audience. ACS tokens are requested for https://communication.azure.com/.default; for Groundmail you add the Groundmail app to your tenant once and request tokens for https://api.groundmail.dev/.default instead. No app registration of your own is needed:

  1. Add the Groundmail app to your tenant (once, usually by a tenant admin): az ad sp create --id 0146ad5c-4051-4703-aece-e4edf9805d8e.
  2. In Groundmail, open API keys → Azure managed identity and register your workload's tenant ID and managed identity object ID, with Send only access (or limit it to a domain or tenant). Leave the audience empty.
  3. Swap the scope in your code and send the token as the Bearer credential:
// Before: ACS with a managed identity
var client = new EmailClient(new Uri("https://<resource>.communication.azure.com"), new DefaultAzureCredential());

// After: Groundmail with the same managed identity
var credential = new DefaultAzureCredential();
var token = await credential.GetTokenAsync(new TokenRequestContext(new[] { "https://api.groundmail.dev/.default" }));
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);

Groundmail checks every token's signature against Microsoft's published keys, its issuer, expiry and audience, and that it belongs to an identity you registered. Delegated (user) tokens are refused. In Node, pass the credential to the SDK: new Groundmail({ getToken: () => credential.getToken('https://api.groundmail.dev/.default') }); in Python, Groundmail(token_provider=lambda: credential.get_token("https://api.groundmail.dev/.default")).

Full setup in the Azure portal and CLI, C#, Node and Python code, permissions and troubleshooting: Authenticate from Azure with managed identity.

Two exceptions use credentials: the SMTP relay, which authenticates with an SMTP credential because SMTP can't carry an Entra token, and local development, where you can use a test API key (gm_test_…). The examples further down use an API key so they run anywhere; swap in the token as above.


Quick mapping

ACS Email Groundmail
Entra ID (managed identity, DefaultAzureCredential) The same managed identity: an Entra token for https://api.groundmail.dev in Authorization: Bearer (setup)
Connection string (endpoint=…;accesskey=…) API key gm_… in Authorization: Bearer
POST {endpoint}/emails:send?api-version=… POST https://api.groundmail.dev/emails
202 Accepted + Operation-Location to poll 200 with { "id": "msg_…", "status": "sent" }, no polling
Event Grid EmailDeliveryReportReceived Webhooks: email.delivered, email.bounced, email.failed, …
Event Grid EmailEngagementTrackingReportReceived Webhooks: email.opened, email.clicked
Email Communication Services resource + connected domain Domains in the dashboard or POST /domains
Azure managed domain (…azurecomm.net) No equivalent: verify your own domain
smtp.azurecomm.net:587, SMTP Username + Entra client secret smtp.groundmail.dev:587, username groundmail + SMTP credential gmsmtp_…
Domain suppression lists Account suppression list (/suppressions), filled automatically by hard bounces and complaints

Field-by-field: the send request

ACS's send body (REST emails:send, and the EmailMessage you pass to beginSend / SendAsync) maps to POST /emails like this:

ACS Email field Groundmail field Notes
senderAddress from Must be on a domain verified in Groundmail. Add a display name with { "email": "…", "name": "Acme" } or "Acme <no-reply@acme.com>".
content.subject subject
content.plainText text
content.html html At least one of html or text is required.
recipients.to[] (address, displayName) to A string or an array of addresses.
recipients.cc[] cc String or array.
recipients.bcc[] bcc String or array.
replyTo[] reply_to String or array (replyTo is accepted too).
attachments[].name attachments[].name (or filename) Up to 10 files, 10 MB each.
attachments[].contentType attachments[].content_type (or contentType)
attachments[].contentInBase64 attachments[].data (or content) Base64, same as ACS.
attachments[].contentId attachments[].content_id (or contentId) Inline images referenced as cid:… in your HTML.
headers headers Custom headers. Transport headers that would break delivery are rejected.
userEngagementTrackingDisabled Domain setting Open and click tracking are set per sending domain (open_tracking, click_tracking on PATCH /domains/{id}), off by default. There's no per-message switch.
Operation-Id request header Idempotency-Key header Retries with the same key return the original response for 24 hours instead of sending twice.

Groundmail fields with no ACS equivalent you may want: tags (key/value pairs stored with the email and sent on webhooks), template (send a published template by alias), scheduled_at, and tenant (send on behalf of one of your customers).


Polling vs. response and webhooks

ACS send is a long-running operation. emails:send returns 202 with an Operation-Location URL; the SDKs wrap that in a poller (beginSend + pollUntilDone, or SendAsync(WaitUntil.Completed, …)), and Succeeded means "out for delivery", not delivered. Actual delivery comes later through Event Grid or Azure Monitor logs.

Groundmail doesn't have an operation to poll. POST /emails validates the request, checks suppressions and returns the email's id:

{ "id": "msg_abc123…", "status": "sent" }

Store the id. Delivery outcomes arrive as webhooks to an HTTPS endpoint you register (dashboard Webhooks, or POST /webhooks). Each delivery is one JSON event signed with HMAC-SHA256 in the Groundmail-Signature header; the Node SDK checks it with verifyWebhookSignature. You can also read the current state of any email with GET /emails/{id}.

Event Grid (ACS) Groundmail webhook
EmailDeliveryReportReceived, status Delivered email.delivered
status Bounced email.bounced (data.bounce_type: hard auto-suppresses the address, block doesn't)
status Failed email.failed
status Suppressed email.suppressed (the send is refused with 422 before it leaves)
status Quarantined / FilteredSpam No one-to-one event. When the receiving server refuses a message, you get email.bounced (bounce_type: block) or email.failed
EmailEngagementTrackingReportReceived, engagementType: View email.opened
engagementType: Click email.clicked
(no equivalent) email.sent, email.delivery_delayed, email.complained, email.held

If your Event Grid subscription pushes to an Azure Function or a webhook endpoint, point a Groundmail webhook at the same app and switch on the type field instead of eventType and data.status. Use Send test event on the dashboard Webhooks page to check your handler before cutover. Full payloads: Webhooks.


Domains

Custom domains

In ACS you add a custom domain to an Email Communication Services resource, publish the domain verification TXT, SPF and DKIM records, then connect the domain to your Communication Services resource. In Groundmail:

  1. Add the domain in the dashboard (Domains) or with POST /domains.
  2. Publish the records Groundmail shows: a DKIM TXT record on Groundmail's own selector, the return-path CNAME, and include:spf.groundmail.dev added to your existing SPF record. On Cloudflare, GoDaddy and Vercel it's one click.
  3. Verify. Groundmail signs with your domain once DKIM and the return-path check out, so DMARC aligns.

Keep the ACS records while you switch. Groundmail's DKIM selector is different from ACS's, so both can sign for the same domain during the move. SPF is the one to be careful with: a domain can only have one SPF record, so merge Groundmail's include into the existing record rather than publishing a second one. Remove the ACS DKIM records and SPF include a few days after cutover.

Your DMARC record stays as it is. Groundmail reads your DMARC aggregate reports for free on every plan, which is a good way to confirm every source sending as your domain passes before you tighten the policy. See DMARC monitoring.

Azure managed domains (…azurecomm.net)

If you send from an Azure managed domain such as donotreply@xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.azurecomm.net, there's nothing to carry over: that domain belongs to Microsoft and goes away with the service. Groundmail doesn't offer a shared sending domain for production mail, so verify a domain you own. A subdomain such as mail.yourcompany.com or notify.yourcompany.com works well and keeps app email separate from your staff mailboxes.

Before your domain is verified, you can test from noreply@mail.groundmail.dev to your own account email.


SMTP relay

ACS SMTP authenticates with an SMTP Username resource linked to a Microsoft Entra application, and the password is that application's client secret. The application also needs a role on the Communication Services resource.

Groundmail's relay uses a plain credential you create in the dashboard (SMTP) or with POST /smtp-credentials. No directory app or role assignment is involved. Managed identity applies to the REST API, not SMTP: if you want no stored secret at all, send through the API with your managed identity (above).

Setting ACS Email Groundmail
Host smtp.azurecomm.net smtp.groundmail.dev
Port 587 (or 25) 587
Encryption STARTTLS, TLS 1.2+ STARTTLS
Username Your SMTP Username (freeform or an email address) groundmail
Password Entra application client secret SMTP credential (gmsmtp_…, shown once)

The relay shares verified domains, limits, suppressions and webhooks with the API. Details: SMTP relay.


Suppression lists

ACS suppression lists are customer-managed lists attached to each domain resource; ACS also suppresses addresses that hard bounce. Groundmail keeps one suppression list per account (and one per tenant if you use tenants):

  • Hard bounces and spam complaints are added automatically.
  • A send to a suppressed address returns 422 with details.suppressed and fires email.suppressed, so nothing reaches the mailbox provider.
  • Manage it in the dashboard (Suppressions) or with the API.

To carry your ACS lists over, export the addresses from your domain suppression lists (Azure portal, management SDK or REST) and add each one:

while read -r email; do
  curl -s -X POST https://api.groundmail.dev/suppressions \
    -H "Authorization: Bearer $GROUNDMAIL_API_KEY" \
    -H "Content-Type: application/json" \
    -d "{\"email\": \"$email\", \"reason\": \"imported from ACS\"}"
done < acs-suppressions.txt

Adding an address that's already on the list is safe. Details: Suppressions.


Code: before and after

HTTP (curl)

ACS Email (REST):

# Requires an HMAC-SHA256 signed request or an Entra ID token.
POST https://<resource>.communication.azure.com/emails:send?api-version=2025-09-01
{
  "senderAddress": "no-reply@acme.com",
  "recipients": { "to": [{ "address": "ada@example.com", "displayName": "Ada" }] },
  "content": {
    "subject": "Reset your password",
    "plainText": "Your reset link: https://acme.com/reset/abc123",
    "html": "<p>Your reset link: <a href=\"https://acme.com/reset/abc123\">reset</a></p>"
  },
  "replyTo": [{ "address": "support@acme.com" }]
}
# → 202 Accepted, then poll Operation-Location until status is Succeeded

Groundmail:

curl -X POST https://api.groundmail.dev/emails \
  -H "Authorization: Bearer $GROUNDMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Acme <no-reply@acme.com>",
    "to": "ada@example.com",
    "subject": "Reset your password",
    "text": "Your reset link: https://acme.com/reset/abc123",
    "html": "<p>Your reset link: <a href=\"https://acme.com/reset/abc123\">reset</a></p>",
    "reply_to": "support@acme.com"
  }'

C# (.NET)

There's no Groundmail NuGet package yet, so call the HTTP API with HttpClient.

ACS Email (Azure.Communication.Email):

using Azure;
using Azure.Communication.Email;

var client = new EmailClient(Environment.GetEnvironmentVariable("ACS_CONNECTION_STRING"));

var message = new EmailMessage(
    senderAddress: "no-reply@acme.com",
    recipientAddress: "ada@example.com",
    content: new EmailContent("Reset your password")
    {
        PlainText = "Your reset link: https://acme.com/reset/abc123",
        Html = "<p>Your reset link: <a href=\"https://acme.com/reset/abc123\">reset</a></p>"
    });
message.ReplyTo.Add(new EmailAddress("support@acme.com"));

EmailSendOperation op = await client.SendAsync(WaitUntil.Completed, message);
Console.WriteLine($"{op.Id}: {op.Value.Status}");

Groundmail (HttpClient):

using System.Net.Http.Headers;
using System.Net.Http.Json;

var http = new HttpClient { BaseAddress = new Uri("https://api.groundmail.dev") };
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("GROUNDMAIL_API_KEY"));

var response = await http.PostAsJsonAsync("/emails", new Dictionary<string, object>
{
    ["from"] = "Acme <no-reply@acme.com>",
    ["to"] = "ada@example.com",
    ["subject"] = "Reset your password",
    ["text"] = "Your reset link: https://acme.com/reset/abc123",
    ["html"] = "<p>Your reset link: <a href=\"https://acme.com/reset/abc123\">reset</a></p>",
    ["reply_to"] = "support@acme.com",
});
response.EnsureSuccessStatusCode();

var result = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
Console.WriteLine($"{result!["id"]}: {result["status"]}");

Attachments use the same base64 you already produce for ACS:

["attachments"] = new[]
{
    new Dictionary<string, string>
    {
        ["name"] = "invoice.pdf",
        ["content_type"] = "application/pdf",
        ["data"] = Convert.ToBase64String(File.ReadAllBytes("invoice.pdf")),
    }
},

If your app sends through System.Net.Mail.SmtpClient or MailKit against smtp.azurecomm.net, you can switch the SMTP settings instead and leave the code alone.

Node.js

ACS Email (@azure/communication-email):

import { EmailClient } from '@azure/communication-email'

const client = new EmailClient(process.env.ACS_CONNECTION_STRING!)

const poller = await client.beginSend({
  senderAddress: 'no-reply@acme.com',
  recipients: { to: [{ address: 'ada@example.com', displayName: 'Ada' }] },
  content: {
    subject: 'Reset your password',
    plainText: 'Your reset link: https://acme.com/reset/abc123',
    html: '<p>Your reset link: <a href="https://acme.com/reset/abc123">reset</a></p>',
  },
  replyTo: [{ address: 'support@acme.com' }],
})
const result = await poller.pollUntilDone()
console.log(result.id, result.status)

Groundmail (npm install groundmail):

import { Groundmail } from 'groundmail'

const client = new Groundmail(process.env.GROUNDMAIL_API_KEY!)

const result = await client.emails.send({
  from: 'Acme <no-reply@acme.com>',
  to: 'ada@example.com',
  subject: 'Reset your password',
  text: 'Your reset link: https://acme.com/reset/abc123',
  html: '<p>Your reset link: <a href="https://acme.com/reset/abc123">reset</a></p>',
  replyTo: 'support@acme.com',
})
console.log(result.id, result.status)

Python

ACS Email (azure-communication-email):

import os
from azure.communication.email import EmailClient

client = EmailClient.from_connection_string(os.environ["ACS_CONNECTION_STRING"])

poller = client.begin_send({
    "senderAddress": "no-reply@acme.com",
    "recipients": {"to": [{"address": "ada@example.com", "displayName": "Ada"}]},
    "content": {
        "subject": "Reset your password",
        "plainText": "Your reset link: https://acme.com/reset/abc123",
        "html": '<p>Your reset link: <a href="https://acme.com/reset/abc123">reset</a></p>',
    },
    "replyTo": [{"address": "support@acme.com"}],
})
result = poller.result()
print(result["id"], result["status"])

Groundmail (pip install groundmail):

import os
from groundmail import Groundmail

client = Groundmail(os.environ["GROUNDMAIL_API_KEY"])

result = client.emails.send(
    from_="Acme <no-reply@acme.com>",
    to="ada@example.com",
    subject="Reset your password",
    text="Your reset link: https://acme.com/reset/abc123",
    html='<p>Your reset link: <a href="https://acme.com/reset/abc123">reset</a></p>',
    reply_to="support@acme.com",
)
print(result["id"], result["status"])

Groundmail also has official SDKs for Go, PHP (with Laravel and Symfony Mailer packages) and Ruby. See SDKs.


Checklist

  1. Create a Groundmail account. Add the Groundmail app to your Entra tenant and register your workload's managed identity under API keys → Azure managed identity to stay keyless, or create an API key (gm_…).
  2. Add your sending domain and publish the DNS records. Keep the ACS records in place.
  3. If you send from an …azurecomm.net address, pick a domain or subdomain you own and update your from addresses.
  4. Swap the send call (or the SMTP settings) using the tables above.
  5. Recreate your Event Grid handlers as Groundmail webhooks and send a test event.
  6. Import your ACS suppression lists.
  7. Move traffic over gradually behind a flag, watch bounces and complaints in the dashboard, then remove the ACS DNS records.

You have until September 30, 2028, so there's time to do this carefully. A cutover plan that keeps the old provider as a fallback: switch providers without downtime.


Compare

Retirement facts, what's affected, and how Groundmail compares on price and features: Azure Communication Services Email alternative.