How to track achievements from a Go 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-go-example-app, a small HTTP service on Go 1.22 that receives an unlock from the game and forwards it to Game Stats AI using only the standard library's net/http and encoding/json. 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
The app reads the API host, account id, and token from environment variables, so no secrets live in the repository. Set the account id and token wherever the app runs:
export GAMESTATS_ACCOUNT_ID=your_account_id
export GAMESTATS_TOKEN=server_your_token_here
A small struct holds the three values, filled in from the environment. GAMESTATS_BASE_URL is optional and defaults to the production host:
package main
import "os"
type Config struct {
BaseURL string
AccountID string
Token string
}
func LoadConfig() Config {
baseURL := os.Getenv("GAMESTATS_BASE_URL")
if baseURL == "" {
baseURL = "https://gamestats.ai"
}
return Config{
BaseURL: baseURL,
AccountID: os.Getenv("GAMESTATS_ACCOUNT_ID"),
Token: os.Getenv("GAMESTATS_TOKEN"),
}
}
Reading every value from the environment keeps development and production identical. If you prefer a file for local development, keep the exports in a gitignored file and source it before starting the app.
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. Struct tags map the Go field names to the snake_case names the API expects:
package main
import "time"
type AchievementEvent struct {
VersionName string `json:"version_name"`
PlayerUsername string `json:"player_username"`
AchievementName string `json:"achievement_name"`
OccurredAt time.Time `json:"occurred_at"`
}
The standard library has no global naming policy, so each field states its wire name in its tag.
occurred_at is an ISO 8601 timestamp. encoding/json marshals a time.Time in RFC 3339 format, and a UTC value ends in a trailing Z, so time.Now().UTC() 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: Configure an HTTP client
An http.Client from the standard library is safe for concurrent use, so build one and reuse it for every request. A small struct keeps it alongside the base URL, token, and account id:
func NewGameStatsClient(config Config) *GameStatsClient {
return &GameStatsClient{
httpClient: &http.Client{Timeout: 10 * time.Second},
baseURL: config.BaseURL,
token: config.Token,
accountID: config.AccountID,
}
}
A single shared client handles every request, and the timeout bounds how long a send waits before giving up.
Step 5: Send the event and handle the response
The client marshals the event, posts it, and inspects the result:
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
type GameStatsClient struct {
httpClient *http.Client
baseURL string
token string
accountID string
}
func NewGameStatsClient(config Config) *GameStatsClient {
return &GameStatsClient{
httpClient: &http.Client{Timeout: 10 * time.Second},
baseURL: config.BaseURL,
token: config.Token,
accountID: config.AccountID,
}
}
type validationErrors struct {
Errors []string `json:"errors"`
}
func (client *GameStatsClient) SendAchievement(ctx context.Context, event AchievementEvent) error {
body, err := json.Marshal(event)
if err != nil {
return err
}
url := fmt.Sprintf("%s/api/v1/accounts/%s/achievement_events", client.baseURL, client.accountID)
request, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return err
}
request.Header.Set("Authorization", "Bearer "+client.token)
request.Header.Set("Content-Type", "application/json")
response, err := client.httpClient.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode == http.StatusUnprocessableEntity {
var validation validationErrors
if err := json.NewDecoder(response.Body).Decode(&validation); err != nil {
return err
}
return fmt.Errorf("game stats rejected the event: %s", strings.Join(validation.Errors, "; "))
}
if response.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("game stats returned status %d", response.StatusCode)
}
return nil
}
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 returned error. A wrong or missing token returns 401 or 403 with no body, which the client reports as an unexpected status code.
Step 6: Call it when a player unlocks an achievement
Wherever your backend learns about an unlock, call the client and send the event. The example app does that from an HTTP handler that the game client calls:
package main
import (
"encoding/json"
"log"
"net/http"
"time"
)
type unlockRequest struct {
AchievementName string `json:"achievementName"`
VersionName string `json:"versionName"`
}
func main() {
client := NewGameStatsClient(LoadConfig())
mux := http.NewServeMux()
mux.HandleFunc("POST /players/{username}/achievements", func(writer http.ResponseWriter, request *http.Request) {
var unlock unlockRequest
if err := json.NewDecoder(request.Body).Decode(&unlock); err != nil {
http.Error(writer, err.Error(), http.StatusBadRequest)
return
}
event := AchievementEvent{
VersionName: unlock.VersionName,
PlayerUsername: request.PathValue("username"),
AchievementName: unlock.AchievementName,
OccurredAt: time.Now().UTC(),
}
if err := client.SendAchievement(request.Context(), event); err != nil {
http.Error(writer, err.Error(), http.StatusBadGateway)
return
}
writer.WriteHeader(http.StatusAccepted)
})
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
unlockRequest is a two-field struct with the achievement name and the version name the game client sends. Go 1.22's net/http router matches the method and the {username} path segment, and request.PathValue reads it back.
Sending the event inline is the minimum path. Optionally, retry a failed send, which is safe because resending an unlock has no effect, or move the send off the request path onto a background goroutine or a queue.
The complete project is in game-stats-ai-go-example-app.