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!
 

Thursday, February 26, 2026

Sitecore Hackathon - tips and tricks

Sitecore Hackathon

A few more weeks and it's time for the yearly Sitecore Hackathon. And even though it's hard to match the golden years with over 90 teams participating, 2026 seems to have more than 30 teams again from all over the world. It's still a well known event in the Sitecore community - organized by people with a heart for our Sitecore community so thank you Akshay and all your "community judges" for the effort.


What is this hackathon? 
A hackathon is a fast-paced, collaborative event - in this case lasting 24 hours - where teams of developers and/or domain experts create functional prototypes or software solutions to specific problems. Originating from "hack" and "marathon," these events focus on innovation, networking, and rapid problem-solving.



So basically you get 24h to deliver a project from scratch. And your team of max 3 persons will take up all roles needed to deliver the project. 

I have participated in the Sitecore hackathon for several years with various results. Going from not delivering anything at all in the end to actually really win this thing back in 2017 (oh, time flies).  As I am not participating this year due to a number of reasons I though I might share some lessons that I learned throughout the years. As I learned from the things we did right, the things we did wrong and the many hours of blood, sweat, tears and joy - it feels like post-worthy. 


Before you begin

A good preparation is key to success and that is no different in a hackathon. A few things come to mind in the preparation phase:

Environment

You do not want to be that team that lost hours because they did not have an environment ready for their development. Make sure to check the github access - as requested actually. But your "environment" goes beyond that. Things change of course but to give a few examples of things you do not want to be doing when starting a hackathon:  searching and downloading large Sitecore setup files, windows updates...   Where things like this might sound trivial, they are important. Even cleaning your desk is. 

Also important is a collaboration tool. Even if you are working solo actually. As you will be doing a "project" you do want to keep track of the task you need or want to do. You will want to prioritize them and so on. Depending on your team this can be post-its or any (free) online tool. Pick one, and make sure it is ready to go before the hackathon starts. And you might already be able to add some tasks.

When you read the requirement for a successful entry in the hackathon competition you will notice that there is more than just coding. And subconsciously I actually gave you another task up front - yes, everyone should read the requirements. You should be ready to create documentation with screenshots and video material. Be ready for this - and especially the video. Don't let that final hackathon hour be your first time to create and upload a video as you will be tired and maybe not thinking straight anymore.

Catering

People do need to eat and drink. And as a hackathon is supposed to be fun too, why not go for some nice food and beverages. Yes, sure. Absolutely a fantastic idea as this will contribute to your well-being during a long day. But don't get your hopes up for a feast, as my experience learned me that you will probably not be that hungry at all. So yes, make sure you have everything ready for some nice snacks, drinks and meals but make them rather healthy. Just think what your brain will do after a pizza, double burger, half a bottle wine and a chocolate covered ice cream...  ok, I am exaggerating here but you do get my point.

Timing 

It's gonna be a long day - 24h is a lot and as they give the final idea one hour in advance you could call it 25h.  Some teams take part of that time for a (power) nap. Or even some more sleep if it starts for you in the middle of the night. Depending on your time zone several options are possible. There is no real best practice here as this is also rather personal but just something to think about. 

Idea

One of the challenges of the hackathon is to come up with a decent idea. We will come back to this later as this is also the first task of the day - but you might already be prepared for this. You should not be spending too much time on details up front as there is always a (big) chance everything you thought of is garbage after they announce the actual hackathon idea. But it is a good idea just to be ready with some topics in the back of your head to avoid starting the day with nothing. 


The hackathon day

As I mentioned earlier the hackathon is actually creating a project in a very short time frame. Of course, this is not a full blown project but more like a proof of concept but still. You should go to most of the steps of a project. I'll cover some here and guide you with timings. Time is crucial... 

Idea

Here is the idea again. By now you know the main idea or topic for the hackathon - this can actually also be more than one. So you can start brainstorming keeping in mind the following guidelines:
  • Do not aim too big immediately. This is a proof of concept and it's better to deliver a good mvp (no, not the Sitecore mvp but a minimum viable product). 
  • Try to find an idea that you can scope into a mvp (must) and some extra features (nice to have, if you still have time). This way you can start with the mvp. When that works you will get some peace of mind and decide how to proceed. 
  • Know you team. You should be able to work together so make sure your project can have several tasks that can be done simultaneously by the people in your team. As an example: if 3 person team has only one person who know nextjs you should probably not try a project with 80% nextjs code. 
  • Visibility. It is always nice to show something. Nobody expects a fully designed solution, but something visible is always nice. And if you would need some test content, remember who is going to judge it - it might be something stupid but with test content you can get a smile and a happy judge is better than a grumpy one.
  • Has it been done already? I still remember when I was sitting in our brainstorm room killing several ideas because they were already available in the Sitecore marketplace. And of course it is not because something already exists that it cannot be a good idea but it does make it harder to stand out of course. 
Having a good idea is very important so take your time for it. I remember some years where this took up to 3 hours of our time to get it right. It should not take more than that but do not expect it to be done in half an hour and do not panic if it's not. 

Task list 

After the idea phase (or probably already during) you will start creating a task list. This enables your team also to check the feasibility of the idea. Make sure you have a distinction between the "must haves" (the mvp and the requirements that you need for a valid entry) and the "nice to haves".  This list doesn't have to be complete of course. It's something that lives during the day but it will give a good guidance on your progress and can avoid stress towards the end. 

Coding & fun

It's a hackathon so you will write some code. Or have it generated... at least parts of it as that is how the world runs these days. Try to enjoy it as well. And it is a community thing, so share the joy with some pictures that you can share.  As this is a worldwide event with people from very different time zones, it's always nice to see other teams going for lunch while you are having breakfast ☺

The final

You probably do want to finish at least something and deliver an entry - so the final hours do matter. At least if the other parts went fine but let's assume they did. I remember the organizers sending messages when the deadline came in sight - but if you start getting those and you are not prepared for the final part the stress will start building up. 
In order to deliver a clean and presentable entry you should have a cut off point. My suggestion would be 3 up to even 4h before the deadline. Once that is reached you should be able to finish coding and not pick any new feature tickets anymore. It's time to wrap up and that means quite a few things:
  • Testing. I would assume you have done some tests already during the day but now it's time to do the final round of testing. Make sure everything works. In this era of generated code this had probably become even more important.
  • Do a (final) code review.  Check the code, make sure it is clean and consistent. Clean does not mean with comments by the way - it means that people who are a bit familiar with what you are doing are able to understand without asking an AI assistant. 
  • Documentation. If you haven't done this yet (and let's be honest, you probably will not as this is not the most fun part) it's really time to get started on it. Get the documentation, the video and the installation documentation ready.
  • Testing. Not again? Yes, again. But this time, test your installation document. Test it on a clean environment just like the judges would. This will make sure they are able to install your product because if they cannot, you're out.
  • Check the requirements one last time to make sure you really have everything.  

You might think I am exaggerating again, and you might be right. But I remember those last hours as tiresome, stressful and the brain not doing everything the way it did half a day earlier...  but maybe since AI is doing half our work anyways things have changed ☺.  But whatever you do, do not underestimate the last part as it is really painful to crash just before the finish. 


TLDR

I noticed I wrote way too many text..  so in case you skipped it, here is the summary:
  • be prepared up front
  • read the requirements
  • don't eat 3 pizza's
  • don't forget to test thoroughly - including the installation
  • don't wait for the final bell to wrap up
  • and most of all: don't forget to enjoy it

Good luck !