Wednesday, March 25, 2026

Sitecore Send transactional mails

Sitecore Send transactional mails

We all get too many emails. But aren't you also frustrated when you don't get a confirmation (mail) after an online purchase? Or even after filling in a form that is important to you. Transactional mails are important for people. And not only as confirmation. They can also be used to inform your organization about events on the website like people making request or even application failures. Sometimes they are also used within the (sales) flow. Just think about codes that are being send to verify your login attempt. 

Plenty of examples and I don't think any organization needs to be convinced that transactional mails are still important. So what happens if you are running on a Sitecore XP and using EXM for those mails... because, well that is what you get with XP.  In my case this means  you do a proof of concept with Sitecore Send to show how this can be handled with a modern platform which feels a bit more reliable. 

The proof-of-concept

The idea for the poc was basically simple:
  • can we send emails from an application based on a template which is maintained by an end user
  • can we transform these emails dynamically:
    • filling specific elements with data provided by the application
    • customizing the template (show/hide areas) as needed

Asking a friend

As this seemed very basic, I asked my good friend Claude to create this for me. Now it could be my mistake and the fact that my prompt of 12 lines was still not accurate enough, but after 4 attempts and a direct link to the documentation he was able to get a function that was almost good. The payload to send to the Sitecore Send API was still wrong so I had to fix that myself. 

As it wasn't right on the spot, this felt like something to share. So here we go. 

Dynamic templates

Before we dive into the code, I would like to talk about the dynamic part. Replacing tokens and personalizing complete blocks of the template.

The Sitecore docs mention:
In the Sitecore Send API we use the Mustache templating language to generate and render personalized content based on the data received via the API. This approach allows you to customize the recipient experience by tailoring specific elements, such as text and images, to meet individual preferences and requirements.
So I checked the documentation for the Mustache templating language and found several interesting things. However (and we will come back to this later) they didn't seem to work. Furthermore, the example from Sitecore a syntax like {{#each ...}} which is actually not basic Mustache. So for the POC I ended up with 2 solutions so we can decide later which will suit us best. 

The first solution will send a request to Send for a transactional mail being send to my recipient with substitution tokens using my template. This is the basic out-of-the-box solution with the least custom coding. Sitecore Send will transform the template and handle the delivery. 

A second solution will also use that template but it will add a custom step. In this approach we first fetch the template from Send, use Mustache ourselves to transform the template and then do a request to Send which includes the resulting html. The request to Send is almost the same as in the first one but by providing the html Send will use this instead of the template. Note that we still mention the template to make sure Send can handle the tracking.

The code

To keep it simple and basic, let's skip the wrapper code for an Azure function which is the entry point I used to test the code.  Note that this is POC code - and mainly generated - so don't expect high quality code. This is the quick and somehow working version...  but good enough to get you going.
using Microsoft.Extensions.Logging;
using Stubble.Core.Builders;
using Stubble.Core.Interfaces;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

namespace FunctionAppSend
{
    public class SitecoreSendEmailService
    {
        private static readonly IStubbleRenderer stubbleRendered = new StubbleBuilder().Build();
        private readonly ILogger<SitecoreSendEmailService> _logger;
        private readonly HttpClient _httpClient;
        private readonly string _apiKey;
        private const string ApiBaseUrl = "https://api.sitecoresend.io/v3/";

        // Campaign IDs for different languages
        private readonly Dictionary<string, string> _campaignIds = new()
        {
            { "en", "123456be-6e4e-666b-8bd0-1bbbbcc02089" },
            { "de", "YOUR_GERMAN_CAMPAIGN_ID" },
            { "fr", "YOUR_FRENCH_CAMPAIGN_ID" }
        };

        public SitecoreSendEmailService(ILogger<SitecoreSendEmailService> logger, HttpClient httpClient, string apiKey)
        {
            _logger = logger ?? throw new ArgumentNullException(nameof(logger));
            _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
            _apiKey = apiKey ?? throw new ArgumentNullException(nameof(apiKey));
        }

        public async Task<bool> SendVerificationEmailAsync(string language, string email, string verificationCode, int? payed)
        {
            try
            {
                var campaignId = ResolveCampaignId(language);
                var requestUrl = $"{ApiBaseUrl}campaigns/transactional/send.json?apikey={_apiKey}";
                var substitutions = BuildSubstitutions(email, verificationCode, language, payed);

                var payload = new
                {
                    templateid = campaignId,
                    MailSettings = new
                    {
                        BypassUnsubscribeManagement = new
                        {
                            Enable = true
                        },
                        UnsubscribeLinkManagement = new
                        {
                            IncludeUnsubscribeLink = false
                        }
                    },
                    personalizations = new[]
                    {
                        new
                        {
                            to = new[]
                            {
                                new
                                {
                                    Email = email
                                }
                            },
                            Substitutions = substitutions
                        }
                    }
                };

                return await SendTransactionalAsync(requestUrl, payload, email, "template-based");
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Exception occurred while sending verification email to {Email}", email);
                return false;
            }
        }

        public async Task<bool> SendVerificationEmailWithRenderedContentAsync(string language, string email, string verificationCode, int? payed)
        {
            try
            {
                var campaignId = ResolveCampaignId(language);
                var templateHtml = await GetCampaignTemplateHtmlAsync(campaignId);

                if (string.IsNullOrWhiteSpace(templateHtml))
                {
                    _logger.LogError("No HTML template content was found for campaign {CampaignId}", campaignId);
                    return false;
                }

                var substitutions = BuildSubstitutions(email, verificationCode, language, payed);
                var renderedHtml = stubbleRendered.Render(templateHtml, substitutions);
                var requestUrl = $"{ApiBaseUrl}campaigns/transactional/send.json?apikey={_apiKey}";

                var payload = new
                {
                    templateid = campaignId,
                    content = new[]
                    {
                        new
                        {
                            type = "text/html",
                            value = renderedHtml
                        }
                    },
                    MailSettings = new
                    {
                        BypassUnsubscribeManagement = new
                        {
                            Enable = true
                        },
                        UnsubscribeLinkManagement = new
                        {
                            IncludeUnsubscribeLink = false
                        }
                    },
                    personalizations = new[]
                    {
                        new
                        {
                            to = new[]
                            {
                                new
                                {
                                    Email = email
                                }
                            },
                            Substitutions = substitutions
                        }
                    }
                };

                return await SendTransactionalAsync(requestUrl, payload, email, "rendered-content");
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Exception occurred while sending rendered verification email to {Email}", email);
                return false;
            }
        }

        private async Task<string?> GetCampaignTemplateHtmlAsync(string campaignId)
        {
            var requestUrl = $"{ApiBaseUrl}campaigns/{campaignId}/view.json?apikey={_apiKey}";

            using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
            request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

            _logger.LogInformation("Fetching campaign HTML content for campaign {CampaignId}", campaignId);

            using var response = await _httpClient.SendAsync(request);
            var responseBody = await response.Content.ReadAsStringAsync();

            if (!response.IsSuccessStatusCode)
            {
                _logger.LogError("Failed to fetch campaign details for campaign {CampaignId}. Status: {StatusCode}, Error: {ErrorBody}", campaignId, response.StatusCode, responseBody);
                return null;
            }

            using var document = JsonDocument.Parse(responseBody);

            if (!TryGetPropertyIgnoreCase(document.RootElement, "Context", out var context) ||
                !TryGetPropertyIgnoreCase(context, "HTMLContent", out var htmlContentElement) ||
                htmlContentElement.ValueKind != JsonValueKind.String)
            {
                _logger.LogError("Campaign details response did not contain Context.HTMLContent for campaign {CampaignId}", campaignId);
                return null;
            }

            var htmlContent = htmlContentElement.GetString();

            if (string.IsNullOrWhiteSpace(htmlContent))
            {
                _logger.LogError("Campaign details response contained an empty Context.HTMLContent for campaign {CampaignId}", campaignId);
                return null;
            }

            return htmlContent;
        }

        private async Task<bool> SendTransactionalAsync(string requestUrl, object payload, string email, string mode)
        {
            var jsonPayload = JsonSerializer.Serialize(payload);
            using var content = new StringContent(jsonPayload, Encoding.UTF8, "application/json");
            using var request = new HttpRequestMessage(HttpMethod.Post, requestUrl)
            {
                Content = content
            };

            request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

            _logger.LogInformation("Sending {Mode} verification email to {Email}", mode, email);

            using var response = await _httpClient.SendAsync(request);
            var responseBody = await response.Content.ReadAsStringAsync();

            if (response.IsSuccessStatusCode)
            {
                _logger.LogInformation("Verification email sent successfully to {Email}. Mode: {Mode}. Response: {Response}", email, mode, responseBody);
                return true;
            }

            _logger.LogError("Failed to send verification email to {Email}. Mode: {Mode}. Status: {StatusCode}, Error: {ErrorBody}", email, mode, response.StatusCode, responseBody);
            return false;
        }

        private string ResolveCampaignId(string language)
        {
            if (_campaignIds.TryGetValue(language.ToLowerInvariant(), out var campaignId))
            {
                return campaignId;
            }

            _logger.LogWarning("Language {Language} not supported. Falling back to English", language);
            return _campaignIds["en"];
        }

        private static Dictionary<string, object> BuildSubstitutions(string email, string verificationCode, string language, int? payed)
        {
            var substitutions = new Dictionary<string, object>
            {
                { "Email", email },
                { "VerificationCode", verificationCode },
                { "Language", language },
                { "HasPayed", payed.HasValue && payed.Value > 0 },
                {
                    "order",
                    new
                    {
                        products = new[]
                        {
                            new
                            {
                                name = "Big box",
                                quantity = 3,
                                price = new
                                {
                                    grossValue = 1200,
                                    netValue = 1000
                                },
                                valiant = new
                                {
                                    name = "red",
                                    id = "kev8484j49j9j9"
                                }
                            },
                            new
                            {
                                name = "Small box",
                                quantity = 13,
                                price = new
                                {
                                    grossValue = 120,
                                    netValue = 100
                                },
                                valiant = new
                                {
                                    name = "green",
                                    id = "kev81254"
                                }
                            }
                        }
                    }
                }
            };

            if (payed.HasValue)
            {
                substitutions["Payed"] = payed.Value;
            }

            return substitutions;
        }

        private static bool TryGetPropertyIgnoreCase(JsonElement element, string propertyName, out JsonElement value)
        {
            if (element.ValueKind == JsonValueKind.Object)
            {
                foreach (var property in element.EnumerateObject())
                {
                    if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
                    {
                        value = property.Value;
                        return true;
                    }
                }
            }

            value = default;
            return false;
        }
    }
}

Let's digest what is happening here. 

Solution 1: using Send's dynamic templates

As you can see we are testing various substitutions here. Some were taken from the Sitecore example, some are more towards what we are actually looking for. 

Our first solution is actually pretty simple if you know what you need to do. We need to send a request to the Send API : POST /campaigns/transactional/send.json and attach our API key in the querystring. 
The payload is the data that we send to this API and that will determine what actually happens. We are sending only what is needed for our solution so it will not include a subject or a from address as those come from the template. We are including:
  • templateId: statistics will be registered to the campaign with this ID so although it is not mandatory for the api you probably do want this - and in this scenario it is needed as we use the template to send the mail
  • mailSettings: you can handle unsubscribe settings and scheduled dispatching with these settings
  • personalizations: required and rather important part. Here you define your recipient(s) for the mail and also the substitutions. Substitutions are the values to substitute in the message content and subject for the current recipient (as key/value pairs)
We created all the values we need in a payload object, serialize it and send it with a post request to the Send API.  A few seconds later our mail arrives... 


Solution 2: custom transformation 

In a second solution we first fetch the campaign details with GET /campaigns/{CampaignID}/view.json and filter the HtmlContent from the response. We use this html content and the substitutions to render the final html using Stubble which is an implementation of the Mustache template system in C#.  

We now have the html for the mail - let's send this to Send. We use the same API as in the first solution and almost the same payload. We do add one more value called Content.  Note that this Content is an array although it should only contain one value and you do need to specify both type and value.

After posting this to Send a few seconds later we also get our mail.


The Sitecore Send campaign

I will not discuss the whole process of creating a transactional campaign in Send as that is quite straight forward (and documented). What I do want to show in order to get to our conclusion is what was included in the template. 

Simple substitutions

First of all we have the simple substitutions like "{{VerificationCode}}". Note that these can be placed both in the content of the mail as in the subject. They are replaced perfectly in both places in both solutions (to get this working in solution 2 do not forget to still send the substitutions even though you already used them for the content).

These can be very handy to place personalized data in your mail.

If (not)

We also tried to get the "if" syntax from Mustache to work. It looks like this:
    {{#Payed}} Thank you for your payment of {{Payed}}&euro; {{/Payed}}
    {{^Payed}} Don't forget to pay. {{/Payed}}

Everything inside the {{# part should only be displayed if Payed is present in the substitutions. The {{^ syntax is the negation so that will be displayed if it is not present.  Actually, if the key exists and has a non-false value, the html between the pound and slash will be rendered and displayed one or more times so it can also be used to display lists.

This is standard Mustache but it will not work if you let Send do the transformation as we did in solution 1. It will work in solution 2 as that uses all Mustache transformations.

Each

We also tried the "each" syntax as mentioned in the Sitecore Send documentation. This looks something like:
    {{#each order.products}}
    {{this.name}}
    Quantity: {{this.quantity}} - Total {{this.price.grossValue}}
    {{/each order.products}} 

This works fine in our first solution where Send does the transformation but it does not in our 2nd where we use the actual Mustache library.


Conclusion

It became clear to us that Sitecore Send is probably using their own interpretation of Mustache. There are several of those available online. To be honest I would have liked that the default (and full) mustache language was available as that would be much easier so that feedback will be going towards the Send team. 

But after all - solutions are possible. And both solutions provided here have their use cases. If you don't need more than what is provided by substitutions out-of-the-box the first solution is a very easy way to get your transactional mails out. 
If you do need more flexibility and more complex logic in your templates, the second solution also works fine and can get your mails to their recipients with tracking in Send.


Let's start Send-ing those mails!