How to track achievements from a C# backend
By Mike Dalton ·
Achievement tracking records the milestones players reach in your game: clearing a level, finding a hidden item, defeating a boss. Those records show how far players get, where they stop, and which content they never find.
If your game already talks to a backend you control, that backend is a good place to send achievement events from. Your API token stays on your servers instead of shipping inside the game client, and the integration works the same no matter which engine the client uses.
To show what that looks like, we built game-stats-ai-csharp-example-app, a small ASP.NET Core service on .NET 8 that receives an unlock from the game and forwards it to Game Stats AI using only HttpClient and System.Text.Json from the base class library. This guide walks through it step by step.
Step 1: Create a server token
Game Stats AI has two kinds of API tokens. Client tokens can only send telemetry, so they are safe to embed in a game client. Server tokens can send telemetry and read reports, and they must stay on a trusted backend.
Since this code runs on your backend, create a server token at gamestats.ai/api_tokens. The same page shows the account id that the endpoint URL needs.


Step 2: Store the token and account id in configuration
appsettings.json only holds the API host:
"GameStats": {
"BaseUrl": "https://gamestats.ai"
}
The token is a secret, and storing the account id the same way keeps both out of the repository. In development, use user secrets:
dotnet user-secrets set "GameStats:AccountId" "your_account_id"
dotnet user-secrets set "GameStats:Token" "server_your_token_here"
In production, set them as the GameStats__AccountId and GameStats__Token environment variables. All sources bind to the same section, so the rest of the code never needs to know where the values came from.
An options class holds the three values:
namespace GameStatsExample;
public sealed class GameStatsOptions
{
public string BaseUrl { get; set; } = "https://gamestats.ai";
public string AccountId { get; set; } = "";
public string Token { get; set; } = "";
}
Step 3: Define the event payload
Game Stats AI accepts achievement events at a single endpoint:
POST https://gamestats.ai/api/v1/accounts/{account_id}/achievement_events
Authorization: Bearer <token>
Content-Type: application/json
The body is four required fields. A record with JsonPropertyName attributes maps the C# names to the snake_case names the API expects:
using System.Text.Json.Serialization;
namespace GameStatsExample;
public sealed record AchievementEvent(
[property: JsonPropertyName("version_name")] string VersionName,
[property: JsonPropertyName("player_username")] string PlayerUsername,
[property: JsonPropertyName("achievement_name")] string AchievementName,
[property: JsonPropertyName("occurred_at")] DateTime OccurredAt);
On .NET 8 you can drop the attributes and set PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower in JsonSerializerOptions instead; the attributes keep the wire format visible in one place.
occurred_at is an ISO 8601 timestamp. System.Text.Json serializes a UTC DateTime in that format with a trailing Z, so DateTime.UtcNow needs no format string.
Achievements, players, and versions are created the first time they appear in an event, so there is nothing to register in advance. Sending the same achievement for the same player again has no effect, which makes retries safe.
Step 4: Register a typed HttpClient
Register a typed client in Program.cs. IHttpClientFactory manages connection lifetimes, and the factory callback reads the options to set the base address and the bearer token once:
builder.Services.Configure<GameStatsOptions>(builder.Configuration.GetSection("GameStats"));
builder.Services.AddHttpClient<GameStatsClient>((services, client) =>
{
var options = services.GetRequiredService<IOptions<GameStatsOptions>>().Value;
client.BaseAddress = new Uri(options.BaseUrl);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", options.Token);
});
If your app does not use dependency injection, a single long-lived HttpClient configured the same way works too.
Step 5: Send the event and handle the response
The client posts the record and inspects the result:
using System.Net;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Options;
namespace GameStatsExample;
public sealed class GameStatsClient(HttpClient http, IOptions<GameStatsOptions> options)
{
private readonly string _accountId = options.Value.AccountId;
public async Task SendAchievementAsync(AchievementEvent achievementEvent, CancellationToken cancellationToken = default)
{
var response = await http.PostAsJsonAsync(
$"api/v1/accounts/{_accountId}/achievement_events", achievementEvent, cancellationToken);
if (response.StatusCode == HttpStatusCode.UnprocessableEntity)
{
var body = await response.Content.ReadFromJsonAsync<ValidationErrors>(cancellationToken);
throw new InvalidOperationException(string.Join("; ", body?.Errors ?? []));
}
response.EnsureSuccessStatusCode();
}
}
public sealed record ValidationErrors(
[property: JsonPropertyName("errors")] string[] Errors);
A queued event returns 201 Created with an empty JSON object. A payload that fails validation returns 422 Unprocessable Entity with the reasons, like {"errors":["Achievement name can't be blank"]}, and the client surfaces them in the exception message. A wrong or missing token returns 401 or 403 with no body, which EnsureSuccessStatusCode turns into an HttpRequestException.
Step 6: Call it when a player unlocks an achievement
Wherever your backend learns about an unlock, inject GameStatsClient and send the event. The example app does that in a minimal API endpoint that the game client calls:
app.MapPost("/players/{username}/achievements", async (string username, UnlockRequest request, GameStatsClient gameStats, CancellationToken cancellationToken) =>
{
await gameStats.SendAchievementAsync(new AchievementEvent(
VersionName: request.VersionName,
PlayerUsername: username,
AchievementName: request.AchievementName,
OccurredAt: DateTime.UtcNow), cancellationToken);
return Results.Accepted();
});
UnlockRequest is a two-field record with the achievement name and the version name the game client sends.
Awaiting the send inline is the minimum path. Optionally, add the standard resilience handler from Microsoft.Extensions.Http.Resilience for automatic retries, which is safe because resending an unlock has no effect, or move the sends to a background queue with Channel<T> and a hosted service if you do not want them on the request path.
The complete project is in game-stats-ai-csharp-example-app.