SparkPoster 0.3.0

dotnet add package SparkPoster --version 0.3.0
                    
NuGet\Install-Package SparkPoster -Version 0.3.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="SparkPoster" Version="0.3.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="SparkPoster" Version="0.3.0" />
                    
Directory.Packages.props
<PackageReference Include="SparkPoster" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add SparkPoster --version 0.3.0
                    
#r "nuget: SparkPoster, 0.3.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package SparkPoster@0.3.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=SparkPoster&version=0.3.0
                    
Install as a Cake Addin
#tool nuget:?package=SparkPoster&version=0.3.0
                    
Install as a Cake Tool

SparkPoster

A modern .NET client for the SparkPost REST API: fluent transmission building, webhook receiving, async/await throughout, trimming- and AOT-friendly.

Which API is this? SparkPost is now Bird Email, and Bird ships a newer API at platform.bird.com with official SDKs for TypeScript, Go and Python. This library targets the legacy SparkPost v1 API at api.sparkpost.com/api/v1, which is what existing SparkPost accounts and keys work against. Neither API has an official .NET SDK.

Install

dotnet add package SparkPoster
dotnet add package SparkPoster.Extensions.DependencyInjection   # ASP.NET Core / generic host
dotnet add package SparkPoster.AspNetCore                       # receiving webhooks

Targets net8.0, so it runs on .NET 8, 9 and 10.

Send a message

var client = new SparkPostClient(new SparkPostOptions { ApiKey = apiKey });

var transmission = Transmission.Create()
    .From("noreply@example.com", "Example")
    .To("user@example.com")
    .Cc("boss@example.com")
    .Subject("Hi {{name}}")
    .Html("<p>Hi {{name}}</p>")
    .SubstitutionData(new { name = "Bob" })
    .Transactional()
    .Build();

var result = await client.Transmissions.SendAsync(transmission, cancellationToken: ct);
// result.Id, result.TotalAcceptedRecipients, result.IsIdempotentReplay

That constructor is for console applications, scripts and tests: it uses one shared HttpClient for the process. Inside a host, register the client instead — see below.

The builder never sends anything: Build() hands back a serializable TransmissionRequest, so a message can be assembled now, queued, and sent later. When you do queue it, store the idempotency key next to it — a key derived from your own identifier, such as the order number — and pass that key to every attempt at sending; a request replayed from the queue then cannot send the message twice.

SubstitutionData(new { name = "Bob" }) serializes with reflection, which trimmed and Native AOT builds do not have. Both have two alternatives: a JsonNode needs no serializer context at all, and a JsonTypeInfo<T> from your own source-generated context handles a real model:

.SubstitutionData(new JsonObject { ["name"] = "Bob" })
.SubstitutionData(model, MyJsonContext.Default.WelcomeModel)

Content can be given in any one of the four forms SparkPost supports — inline, a stored template, an A/B test, or raw RFC822 — and mixing them is caught in Build():

Transmission.Create().To("user@example.com").Template("welcome", useDraft: true).Build();
Transmission.Create().To("user@example.com").AbTest("subject-test").Build();
Transmission.Create().To("user@example.com").RawRfc822(mime).Build();
Transmission.Create().RecipientList("christmas-2026").Template("promo").Build();

Attachments are Base64 inside the JSON body — that is all SparkPost accepts, so there is no streaming to be had, and the 20 MB content cap applies:

.Attach(await Attachment.FromFileAsync("invoice.pdf", "application/pdf", cancellationToken: ct))

Register it in DI

builder.Services
    .AddSparkPost(builder.Configuration.GetSection("SparkPost"))
    .AddStandardResilienceHandler();               // Microsoft.Extensions.Http.Resilience

// or in code, when the key comes from somewhere configuration cannot reach:
builder.Services.AddSparkPost(options =>
{
    options.ApiKey = apiKey;
    options.BaseUrl = SparkPostEndpoints.Eu;       // defaults to the US service
});

A missing or empty ApiKey fails when the client is built, with a message saying so — not later, as SparkPost's 401 to a request that carried an empty Authorization header.

AddSparkPost returns the IHttpClientBuilder, so retries, timeouts and circuit breaking are configured with the standard Microsoft handler rather than a home-grown one.

Retrying a send is safe by construction. Every transmission carries an Idempotency-Key header, generated automatically unless you pass your own. A retry inside a DelegatingHandler replays the very same request with the very same key, and SparkPost returns the original result instead of sending a second message — IsIdempotentReplay tells the two apart. When your own code retries a send, pass a key derived from a business identifier:

await client.Transmissions.SendAsync(transmission, idempotencyKey: $"order-{orderId}", ct);

The key must match ^[A-Za-z0-9._-]{1,255}$; anything else is rejected before the request is built, so hash or encode an identifier that carries other characters.

Other writes are not covered. Creating a webhook, a template or a sending domain has no idempotency key, and the standard handler retries every method by default: a lost response followed by a retry creates the webhook twice, or fails the template with a conflict. If that matters, either give such resources explicit identifiers and treat a conflict as success, or keep the retries to what is safe — reads, and sends:

.AddStandardResilienceHandler(options =>
{
    var isTransient = options.Retry.ShouldHandle;
    options.Retry.ShouldHandle = args =>
        args.Context.GetRequestMessage() is { Method: { Method: "POST" } } request
            && !request.RequestUri!.AbsolutePath.EndsWith("/transmissions", StringComparison.Ordinal)
            ? ValueTask.FromResult(false)
            : isTransient(args);
});

Subaccounts

await client.ForSubaccount(42).Transmissions.SendAsync(transmission, cancellationToken: ct);

Note that Metrics and Events ignore the subaccount header; they filter through the subaccounts query parameter instead, which EventQuery.Subaccounts exposes.

Webhooks

Create and manage them:

var id = await client.Webhooks.CreateAsync(
    new WebhookRequest
    {
        Name = "Delivery events",
        Target = "https://app.example.com/hooks/sparkpost",
        Events = [SparkPostEventTypes.Delivery, SparkPostEventTypes.Bounce],
        AuthType = WebhookAuthType.Basic,
        AuthCredentials = new WebhookAuthCredentials { Username = "hook", Password = secret },
    },
    ct);

Receive them:

app.MapSparkPostWebhook(
    "/hooks/sparkpost",
    async (batch, ct) =>
    {
        foreach (var @event in batch.Events)
        {
            if (@event is MessageEvent { Type: SparkPostEventTypes.Bounce } bounce)
            {
                await suppress.RecordAsync(bounce.RcptTo!, bounce.Reason, ct);
            }
        }
    },
    new SparkPostWebhookOptions { BasicAuthUsername = "hook", BasicAuthPassword = secret });

The options argument is required. SparkPost webhooks carry no signature, so an endpoint with nothing configured would accept forged events from anyone who learns its URL; a half-filled pair — a header name without its value — throws at startup rather than quietly letting everyone through. If a gateway in front already does the checking, say so with AllowAnonymous = true.

Outside ASP.NET Core, SparkPostWebhookParser.ParseAsync(stream, ct) does the parsing on its own.

Three things about webhook delivery that the design of this API forces on you:

  • Batches repeat. Delivery is at-least-once and unordered, and a batch that does not get a 200 is retried for 8 hours. Deduplicate on batch.BatchId or event.EventId.
  • Exceptions are not swallowed. A handler that throws produces a 500, which is what makes SparkPost resend. Catching everything and answering 200 silently turns at-least-once delivery into at-most-once.
  • Ten seconds. That is how long SparkPost waits for your response. If processing takes longer, queue the batch — but then its safekeeping is yours, not SparkPost's.

Every event SparkPost documents lands in a typed record: MessageEvent, TrackEvent, GenerationEvent, UnsubscribeEvent, RelayEvent, AbTestEvent, IngestEvent. Fields that several categories share — recipient, tracking flags, injection time, failure reason — sit on the SparkPostEvent base, so a handler can deduplicate and log without a type switch. Anything not typed stays in Extra.

An event the library cannot type arrives as UnknownSparkPostEvent and never throws — a new category must not make you answer 500 and have the whole batch resent. Its common fields are still filled in, Raw holds the payload, and ParseError tells a genuinely new category (null) apart from a known one whose body did not fit the model (the reason). Store such events, or at least their Raw, rather than dropping them; once the library learns the type, they can be reprocessed. A body that is not a SparkPost batch at all — no msys wrapper — is answered 400.

Events

Two ways to read them, because two different jobs need them:

// Walk everything; pages are fetched lazily as you go.
await foreach (var @event in client.Events.SearchAsync(new EventQuery { Campaigns = ["blackfriday"] }, ct))
{
    Console.WriteLine($"{@event.Timestamp:u} {@event.Type} {@event.RcptTo}");
}

// Or drive the cursor yourself, when it has to survive a restart.
var page = await client.Events.GetPageAsync(query, cursor: savedCursor, ct);
await checkpoints.SaveAsync(page.NextCursor, ct);

Event data is retained for 10 days.

Templates, suppression list, sending domains

await client.Templates.CreateAsync(new TemplateRequest { Id = "welcome", Content = content }, ct);
await client.Templates.PublishAsync("welcome", ct);           // draft -> published

await client.SuppressionList.UpsertAsync(
    new SuppressionEntry { Recipient = "user@example.com", Type = SuppressionTypes.NonTransactional },
    ct);

var domain = await client.SendingDomains.CreateAsync(new SendingDomainRequest { Domain = "example.com" }, ct);
// publish domain.Dkim in DNS, then:
var status = await client.SendingDomains.VerifyAsync("example.com", cancellationToken: ct);

Templates.UpdateAsync replaces content as a whole when it is given — SparkPost does not patch individual fields — so send every field you want to keep, or leave Content null to change only the name, description or options. A template's from may come back as a string such as "{{ friendly_from }} <team@example.com>"; it is kept verbatim in From.Email and written back as a string, so the expression survives a read-modify-write.

Errors

Everything non-2xx becomes a SparkPostApiException carrying StatusCode, the parsed Errors and the raw body. Rate limiting (429) and the sending limit (420) come back as SparkPostRateLimitException, which adds RetryAfter.

catch (SparkPostRateLimitException e) { await Task.Delay(e.RetryAfter ?? TimeSpan.FromSeconds(5), ct); }
catch (SparkPostApiException e) when (e.StatusCode == HttpStatusCode.UnprocessableEntity)
{
    logger.LogWarning("SparkPost rejected the message: {Reason}", e.Errors.FirstOrDefault()?.Description);
}

Security

  • SparkPost webhooks carry no signature. Authenticity rests entirely on what you configured when creating the webhook. Serve the endpoint over HTTPS; MapSparkPostWebhook refuses to start without either Basic authentication or a secret header, and compares them in constant time.
  • Keep the API key out of configuration files. Environment variables or a secret store; the library never logs it, and never puts it in an exception message.
  • Secrets are masked in ToString(). A record normally prints every property, so WebhookAuthCredentials, WebhookAuthRequestDetails, DkimSettings and Attachment override that — a webhook read back from the API can otherwise carry its own password into your logs.
  • SparkPostApiException.RawBody can hold personal data — validation errors echo recipient addresses back, and Message carries the first error's description verbatim. Think before dumping either into logs.
  • BaseUrl must be https://. The key travels in a header; the client refuses a plain http:// base unless it points at a loopback address, which is what a local stub needs.

What is covered

Section Status
Transmissions Send (all four content forms), attachments, inline images, CC/BCC, scheduling, stored recipient lists, cancel by campaign
Event webhooks CRUD, validate, batch status, event documentation and samples
Webhook receiving Typed events for all seven categories, unknown types preserved with their common fields, ASP.NET Core endpoint
Events Message events: cursor paging and lazy enumeration, every documented filter. Ingest event search: not yet
Templates CRUD, drafts and publishing, preview
Suppression list Upsert, bulk upsert, search, delete, summary
Sending domains CRUD and verification
Metrics, A/B testing, snippets, recipient lists, subaccounts, API keys, IP pools, sending IPs, inbound domains, relay webhooks, tracking domains, DKIM keys, data privacy Not yet

Unknown fields are never dropped: every event exposes them through Extra, and unknown event types arrive as UnknownSparkPostEvent rather than breaking the batch. Every official sample event — 27 webhook, 18 Events API — is checked in as a fixture and has to parse into its typed model; the SMS-specific fields of sms_status are the one thing left in Extra on purpose.

Requirements

.NET 8 or later. No dependencies in the core package; the DI package adds Microsoft.Extensions.Http.

License

MIT.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net8.0

    • No dependencies.

NuGet packages (2)

Showing the top 2 NuGet packages that depend on SparkPoster:

Package Downloads
SparkPoster.Extensions.DependencyInjection

IServiceCollection registration for SparkPoster on top of IHttpClientFactory.

SparkPoster.AspNetCore

Receiving SparkPost webhooks in ASP.NET Core: MapSparkPostWebhook, basic-auth and secret-header verification.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.3.0 250 9/6/2026
0.2.0 135 8/29/2026
0.1.0 361 8/29/2026