Serverless Microservices for Static Sites: Azure Functions + Mailchimp Custom Signup (C#)
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?
- Security: your Mailchimp API key stays server-side (in Function App settings), never in client JS.
- UX control: render your own success/error states; no Mailchimp redirect.
- Composability: the same approach scales into a microservices backend (contact form, lead capture, feedback, etc.) for any static site host (Blob Storage, GitHub Pages, Netlify, Cloudflare Pages…).
Prerequisites
- Any static site (hosted anywhere).
- An Azure subscription and one Function App.
- .NET 8 SDK and Azure Functions Core Tools for local runs (optional but recommended).
- A Mailchimp account with API key, Audience (List) ID, and server prefix.
Mailchimp: API Key, Audience ID & Merge Fields
- API key: Mailchimp → Profile → Extras → API keys.
- Audience (List) ID: Audience → Your audience → Settings → Audience name and defaults.
- Server prefix (
dc): the part after the-in the API key (e.g.xxxx-us21→us21). - Merge fields mapping: configure which request fields map to which Mailchimp tags via an env var.
We’ll store these in Function App settings:
MAILCHIMP_API_KEYMAILCHIMP_LIST_IDMAILCHIMP_DCMAILCHIMP_MERGE_FIELDS→ e.g.FNAME:firstName,LNAME:lastName,AGE
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;
}
}
MAILCHIMP_MERGE_FIELDSlets you flexibly map form fields to Mailchimp merge tags, so the same function supports multiple forms.doubleOptInstill controls pending vs. subscribed.- “Member Exists” is treated as success with a friendly message.
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)
- Validation & Abuse Protection: sanitize/validate inputs; throttle by IP; add hCaptcha/reCAPTCHA and verify tokens in the function.
- CORS: restrict to your exact domain(s).
- Observability: enable Application Insights to trace failures and Mailchimp responses.
- Config per environment: use different audiences or tags for dev/staging/prod.
- GDPR & Consent: capture consent fields and pass them as tags/merge fields.
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:
- Contact form → send via SendGrid
- Download gate → issue signed URLs from Blob Storage
- Lead enrichment → write to a lightweight store (Table Storage/Cosmos DB)
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.