Send Push Notifications From C# and .NET
One HttpClient call sends a push notification from C# to your phone. No SDK, no certificates, and a typed client for ASP.NET Core at the end.

A worker service runs a sync every night. It has logging, it has health checks, and it writes a tidy summary to Application Insights that you look at roughly never.
The gap is the same one every backend has: the process knows exactly when it finished and exactly whether it worked, and none of that reaches the person who would want to know. In .NET it is one HttpClient call away.
What you need
- .NET 6 or later, so
System.Text.JsonandHttpClientare already there - An app key from TheNotificationApp
- The iOS app installed and signed in with the same Apple ID
No NuGet package. The two things this needs ship with the framework.
Step 1: The notifier
using System.Net.Http.Json;
using System.Text.Json.Serialization;
public sealed record NotificationRequest(
[property: JsonPropertyName("title")] string Title,
[property: JsonPropertyName("body")] string Body,
[property: JsonPropertyName("link")] string? Link = null,
[property: JsonPropertyName("image")] string? Image = null);
public sealed class PhoneNotifier(HttpClient http, ILogger<PhoneNotifier> logger)
{
private const string Endpoint = "https://thenotification.app/api/sendNotification";
/// <summary>Sends a notification. Never throws: alerting must not fail the job.</summary>
public async Task NotifyAsync(
string title, string body, string? link = null, CancellationToken ct = default)
{
try
{
var payload = new NotificationRequest(
title,
body.Length <= 180 ? body : body[..180],
link);
using var response = await http.PostAsJsonAsync(Endpoint, payload, ct);
if (!response.IsSuccessStatusCode)
{
var detail = await response.Content.ReadAsStringAsync(ct);
logger.LogWarning("Notification failed: {Status} {Detail}",
(int)response.StatusCode, detail);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Notification failed");
}
}
}Two things worth pointing out. PostAsJsonAsync serialises the record and sets the content type in one call, so there is no manual StringContent and no chance of forgetting the header.
And the nullable properties are omitted rather than serialised as null, because System.Text.Json skips nulls when you configure it to. Add that in the DI registration below, or set JsonIgnoreCondition.WhenWritingNull on the options. Sending "link": null for an optional field is a reliable way to get a 400 from an API that expected it to be absent.
Step 2: Register it
Do not new up an HttpClient per call. That is the classic .NET socket exhaustion bug, and the framework has had the answer for years:
builder.Services.AddHttpClient<PhoneNotifier>(client =>
{
client.DefaultRequestHeaders.Add("app_key", builder.Configuration["Notifications:AppKey"]);
client.Timeout = TimeSpan.FromSeconds(10);
});The key goes in configuration, sourced from an environment variable or user secrets in development. The explicit timeout matters because this call sits at the end of a long job, and a hung request there means the job never reports at all.
Step 3: Call it when something finishes
public sealed class NightlySyncWorker(
PhoneNotifier notifier,
ISyncService sync,
ILogger<NightlySyncWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var started = Stopwatch.GetTimestamp();
try
{
var count = await sync.RunAsync(stoppingToken);
var elapsed = Stopwatch.GetElapsedTime(started);
await notifier.NotifyAsync(
"Nightly sync finished",
$"{count:N0} records in {elapsed.TotalSeconds:F0}s",
"https://dashboard.example.com/sync",
stoppingToken);
}
catch (Exception ex)
{
logger.LogError(ex, "Nightly sync failed");
await notifier.NotifyAsync(
"Nightly sync FAILED",
$"{ex.GetType().Name}: {ex.Message}",
ct: CancellationToken.None);
throw;
}
}
}Note the CancellationToken.None in the catch block. If the job failed because the host is shutting down, stoppingToken is already cancelled, and passing it would cancel the notification too. That is exactly the case where you most want the alert, so it gets its own token.
Stopwatch.GetElapsedTime is the .NET 7 way of doing this without allocating a Stopwatch instance. On older versions use Stopwatch.StartNew().
Without dependency injection
For a console app or a script, a static client is fine:
using System.Net.Http.Json;
var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
http.DefaultRequestHeaders.Add("app_key", Environment.GetEnvironmentVariable("TNA_APP_KEY"));
await http.PostAsJsonAsync(
"https://thenotification.app/api/sendNotification",
new { title = "Export finished", body = "4,812 rows written to blob storage" });An anonymous object works perfectly for the ad-hoc case. The record type earns its place once more than one part of the codebase sends notifications.
Keeping it out of your tests
Once a notification call sits inside a worker, every test that exercises that worker tries to reach the internet. Slow, flaky, and eventually it sends a real notification from CI at three in the morning.
An interface and a no-op implementation solve it in a few lines:
public interface IPhoneNotifier
{
Task NotifyAsync(string title, string body, string? link = null, CancellationToken ct = default);
}
public sealed class NullPhoneNotifier : IPhoneNotifier
{
public Task NotifyAsync(string title, string body, string? link = null, CancellationToken ct = default)
=> Task.CompletedTask;
}Then register it by environment, so local runs and CI stay silent without anyone remembering to disable anything:
if (builder.Environment.IsProduction())
{
builder.Services.AddHttpClient<IPhoneNotifier, PhoneNotifier>(client =>
{
client.DefaultRequestHeaders.Add("app_key", builder.Configuration["Notifications:AppKey"]);
client.Timeout = TimeSpan.FromSeconds(10);
});
}
else
{
builder.Services.AddSingleton<IPhoneNotifier, NullPhoneNotifier>();
}There is a real tradeoff here. Anything that only runs in production is something you have never actually run, so keep the manual test from the console snippet above and fire it once against production configuration after a deploy. An alerting path nobody has exercised is not an alerting path.
Handling the responses
Three codes are worth knowing by sight:
- 401: the
app_keyheader is missing or wrong. In DI setups this usually means the configuration key did not resolve and you added a header with a null value. - 400:
titleorbodyis missing. With the record type this is close to impossible. - 429: out of quota. The body states the limit and how many you have sent.
The full field list and every error shape are in the API reference.
The honest part
The free tier is 100 notifications for the lifetime of the account, not per month. One per nightly run is months of headroom. One per record processed is gone before the first job finishes, so keep the call at the job boundary.
Pro is $2.99 a month for 1,000. And this reaches iPhone only, because it rides Apple's push service, which is worth knowing if your team is mixed.
Where this fits
Same endpoint, same two headers, same four fields, whatever the language. The Java version is in sending push notifications from Java, and the concept behind it is in what a push notification API actually is.
Grab a free key at thenotification.app and let the worker service tell you how last night went.



