Logo
Sign Up
← All posts

How to track achievements from a Go backend

Mike Dalton By Mike Dalton ·

A code editor panel and the Go logo beside the Game Stats AI logo, with an arrow pointing to a dashboard card showing a trophy and a bar chart.

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.

The new API token form with the name filled in and the Server token type selected.

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.

© 2026 Rowhome Labs, LLC