← Back to Home

Serverless Microservices for Static Sites: Azure Functions + Mailchimp Custom Signup (C#)

Aug 22, 2025 · 9 min read

Goal: add a fully custom newsletter signup form to a static website without exposing your Mailchimp API key and without redirecting users to Mailchimp’s success page.

Pattern: use an Azure Function as a tiny “microservice” that your static page calls via fetch. The function holds the Mailchimp secret, validates input, talks to Mailchimp, and returns a clean JSON response for your UI.

[ Static HTML/JS ]  →  [ HTTPS ]  →  [ Azure Function (C#) ]
                                          ↓
                                   [ Mailchimp API ]

Why this pattern?

Prerequisites

Mailchimp: API Key, Audience ID & Merge Fields

We’ll store these in Function App settings:

Create the Azure Function (C#, .NET 8 isolated)

Scaffold locally for fast iteration. You can deploy later via CLI or CI.

func init NewsletterFunctions --worker-runtime dotnet-isolated --target-framework net8.0
cd NewsletterFunctions
func new --name Subscribe --template "HTTP trigger"

Minimal Program.cs

using Microsoft.Extensions.Hosting;

var host = new HostBuilder()
    .ConfigureFunctionsWorkerDefaults()
    .ConfigureServices(services =>
    {
        services.AddHttpClient();
    })
    .Build();

host.Run();

The function: HttpTrigger.cs

public class HttpTrigger
{
    private readonly IHttpClientFactory _httpClientFactory;
    private readonly ILogger _logger;

    public HttpTrigger(IHttpClientFactory httpClientFactory, ILogger logger)
    {
        _httpClientFactory = httpClientFactory;
        _logger = logger;
    }

    // Choose auth level. Anonymous here + CORS/captcha. Use Function if you prefer keys.
    [Function("Subscribe")]
    public async Task Run(
        [HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequestData req)
    {
        try
        {
            var request = await JsonSerializer.DeserializeAsync(req.Body, new JsonSerializerOptions
            {
                PropertyNameCaseInsensitive = true
            });

            var email = GetDynamicValue(request, "email") as string;
            if (request is null || string.IsNullOrWhiteSpace(email))
            {
                return await Json(req, HttpStatusCode.BadRequest, new { ok = false, error = "Email is required." });
            }

            if (!IsPlausibleEmail(email))
            {
                return await Json(req, HttpStatusCode.BadRequest, new { ok = false, error = "Invalid email format." });
            }

            var apiKey = Env("MAILCHIMP_API_KEY");
            var listId = Env("MAILCHIMP_LIST_ID");
            var dc = Env("MAILCHIMP_DC") ?? apiKey?.Split('-').LastOrDefault();

            // Configure merge fields via environment variable, e.g. "FNAME,LNAME:LastName,AGE"
            var mergeFieldsList = Env("MAILCHIMP_MERGE_FIELDS")?
                .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);

            if (string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(listId) || string.IsNullOrWhiteSpace(dc))
            {
                _logger.LogError("Missing Mailchimp configuration (API key, LIST ID, or DC).");
                return await Json(req, HttpStatusCode.InternalServerError, new { ok = false, error = "Server configuration error." });
            }

            // Build merge_fields as a dynamic JSON object from configuration.
            // Supports entries like "FNAME" (reads request.FNAME) or "FNAME:FirstName" (maps to request.FirstName).
            var mergeFields = new Dictionary(StringComparer.OrdinalIgnoreCase);
            if (mergeFieldsList is not null)
            {
                foreach (var entry in mergeFieldsList)
                {
                    var parts = entry.Split(':', 2, StringSplitOptions.TrimEntries);
                    var tag = parts[0];
                    var sourceProp = parts.Length == 2 && !string.IsNullOrWhiteSpace(parts[1]) ? parts[1] : parts[0];

                    var value = GetDynamicValue(request, sourceProp);
                    if (value is not null && value is not JsonElement { ValueKind: JsonValueKind.Null })
                    {
                        mergeFields[tag] = value;
                    }
                }
            }

            var client = _httpClientFactory.CreateClient();
            client.BaseAddress = new Uri($"https://{dc}.api.mailchimp.com/3.0/");

            var doubleOptIn = GetDynamicValue(request, "doubleOptIn");
            var tags = GetDynamicValue(request, "tags");

            var body = new
            {
                email_address = email,
                status = doubleOptIn ? "pending" : "subscribed",
                merge_fields = mergeFields, // dictionary serializes as JSON object with dynamic keys
                tags = tags ?? Array.Empty()
            };

            var json = JsonSerializer.Serialize(body);
            using var http = new HttpRequestMessage(HttpMethod.Post, $"lists/{listId}/members");
            http.Content = new StringContent(json, Encoding.UTF8, "application/json");

            // Mailchimp uses HTTP Basic auth: any username + API key as password.
            var token = Convert.ToBase64String(Encoding.ASCII.GetBytes($"anystring:{apiKey}"));
            http.Headers.Authorization = new AuthenticationHeaderValue("Basic", token);

            var resp = await client.SendAsync(http);
            var text = await resp.Content.ReadAsStringAsync();

            if (resp.IsSuccessStatusCode)
            {
                return await Json(req, HttpStatusCode.OK, new
                {
                    ok = true,
                    status = doubleOptIn ? "pending" : "subscribed",
                    message = doubleOptIn
                        ? "Check your inbox to confirm your subscription."
                        : "You are subscribed."
                });
            }

            if ((int)resp.StatusCode == 400 && text.Contains("Member Exists", StringComparison.OrdinalIgnoreCase))
            {
                return await Json(req, HttpStatusCode.OK, new { ok = true, status = "exists", message = "You're already on the list." });
            }

            _logger.LogWarning("Mailchimp error {Status}: {Body}", resp.StatusCode, text);
            return await Json(req, HttpStatusCode.BadGateway, new { ok = false, error = "Mailing service error. Please try again later." });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unhandled exception in Subscribe function");
            return await Json(req, HttpStatusCode.InternalServerError, new { ok = false, error = "Unexpected server error." });
        }
    }

    private static string? Env(string name) => Environment.GetEnvironmentVariable(name);

    private static bool IsPlausibleEmail(string email)
        => email.Contains('@') && email.Contains('.') && email.Length <= 254;

    private static async Task Json(HttpRequestData req, HttpStatusCode code, object payload)
    {
        var res = req.CreateResponse(code);
        await res.WriteAsJsonAsync(payload);
        return res;
    }

    // Extracts a property value from a dynamic object (JsonElement/IDictionary/POCO), case-insensitive.
    private static object? GetDynamicValue(object? obj, string name)
    {
        if (obj is null) return null;

        if (obj is JsonElement je)
        {
            if (TryGetCaseInsensitiveProperty(je, name, out var v)) return v;
            return null;
        }

        if (obj is IDictionary dict)
        {
            foreach (var kvp in dict)
            {
                if (string.Equals(kvp.Key, name, StringComparison.OrdinalIgnoreCase))
                    return kvp.Value;
            }
            return null;
        }

        var pi = obj.GetType().GetProperty(name, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
        return pi?.GetValue(obj);
    }

    private static bool TryGetCaseInsensitiveProperty(JsonElement element, string name, out object? value)
    {
        foreach (var p in element.EnumerateObject())
        {
            if (!string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)) continue;

            value = p.Value.ValueKind switch
            {
                JsonValueKind.String => p.Value.GetString(),
                JsonValueKind.Number => p.Value.TryGetInt64(out var l) ? l :
                                        p.Value.TryGetDouble(out var d) ? d : p.Value.ToString(),
                JsonValueKind.True => true,
                JsonValueKind.False => false,
                JsonValueKind.Null => null,
                _ => p.Value.ToString()
            };
            return true;
        }

        value = null;
        return false;
    }
}
Notes

Connect your static form

Your forms just need to send fields that match the mapping. Example:

<form id="signup">
  <input type="email" name="email" required />
  <input type="text" name="firstName" placeholder="First name" />
  <input type="text" name="lastName" placeholder="Last name" />
  <button type="submit">Subscribe</button>
</form>

<script>
document.getElementById('signup').addEventListener('submit', async (e) => {
  e.preventDefault();
  const f = e.target;
  const res = await fetch('/api/Subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      email: f.email.value,
      firstName: f.firstName.value,
      lastName: f.lastName.value,
      doubleOptIn: true,
      tags: ['website-signup']
    })
  });
  const json = await res.json();
  alert(json.message || json.error);
});
</script>

Production hardening (quick hits)

Testing with curl

curl -X POST "https://<your-func-app>.azurewebsites.net/api/Subscribe" \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","firstName":"Test","lastName":"User","doubleOptIn":true,"tags":["website-signup"]}'

If using function auth:

curl -X POST "https://<your-func-app>.azurewebsites.net/api/Subscribe" \
  -H "Content-Type: application/json" \
  -H "x-functions-key: <your-function-key>" \
  -d '{"email":"test@example.com"}'

Evolving into a microservices backend

Repeat the same pattern for:

Each capability lives as its own small function: deploy independently, test easily, and keep the static site fast.

Wrap-up

With a small C# Azure Function, you get a secure and flexible Mailchimp signup flow that works on any static host. Your API key never touches the browser, you stay in control of the user experience, and you’ve laid the foundation for a clean microservices-style backend that can grow with your site. By keeping the merge field mapping configurable, the same function can support multiple signup scenarios—like newsletters, gated downloads, or event registrations—simply by adjusting MAILCHIMP_MERGE_FIELDS. This keeps your code generic, secure, and adaptable while giving you full ownership of the signup flow.