SDKs

Go SDK

Fetch, verify, cache, and serve Ed25519-signed string bundles on Go servers, with every locale loaded at once and a locale chosen per request.

The AirStrings Go SDK is a server-side library. It loads every locale you configure, verifies each bundle's Ed25519 signature before exposing a single string, keeps bundles fresh in the background, and serves reads lock-free. Each request picks its own locale and experiment assignment.

Requirements: Go 1.26+.

Install

go get github.com/symbionix-sl/airstrings-sdk-go@latest

Setup

Create one Client per process. OrganizationID, ProjectID, EnvironmentID, PublicKeys, and Locales are required.

import "github.com/symbionix-sl/airstrings-sdk-go"

client, err := airstrings.New(airstrings.Config{
	OrganizationID: "org_a1b2c3d4e5f6",
	ProjectID:      "proj_a1b2c3d4e5f6",
	EnvironmentID:  "env_a1b2c3d4e5f6",
	PublicKeys:     []string{"BASE64_ED25519_PUBLIC_KEY"},
	Locales:        []string{"en", "it", "de"},
})
if err != nil {
	log.Fatal(err)
}
defer client.Close()

New returns an error only for invalid configuration. It never blocks on the network: the first fetch runs in the background. To wait for it before serving traffic:

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = client.WaitReady(ctx)
FieldTypeRequiredDescription
OrganizationIDstringyesYour AirStrings organization ID.
ProjectIDstringyesProject ID.
EnvironmentIDstringyesEnvironment ID (e.g. production, staging).
PublicKeys[]stringyesOne or more standard-base64 Ed25519 public keys. Multiple keys supported for rotation.
Locales[]stringyesBCP-47 locales to load (e.g. "en", "pt-BR").
APIBaseURLstringnoAPI base URL. Defaults to https://api.airstrings.com.
RefreshIntervaltime.DurationnoBackground refresh period. Default 5 minutes; negative disables background refresh (the first fetch still happens).
CacheDirstringnoEnables the disk cache. Empty keeps bundles in memory only.
Seedfs.FSnoBundled fallback files named {locale}.json. Default: ./airstrings/bundles if present.
DisableSeedboolnoTurns seeding off.
HTTPClient*http.ClientnoCustom transport (proxies, tracing). The SDK applies its own timeouts.
Logger*slog.LoggernoStructured logs. Default: discarded.
OnUpdatefunc(locale string, revision int64)noCalled after a locale moves to a new revision.
OnErrorfunc(error)noCalled for every failure: rejected bundles, seed and cache errors, network errors.
OnExposurefunc(Exposure)noExperiment exposures, see String variants.

Find your IDs and public key in the dashboard under Project Settings > SDK Configuration, or in the setup snippet shown after you publish a bundle.

Usage

Create a Localizer per request. It is pinned to the bundle revision that was live when you created it, so everything rendered in one response is consistent even if a new revision arrives mid-request.

l := client.Localizer("en", "")
fmt.Println(l.T("greeting"))
fmt.Println(l.Format("items.count", map[string]any{"count": 1200}))

The locale passed to Localizer must match one of Config.Locales exactly; any other locale returns key names.

net/http

Choose the locale per request with golang.org/x/text/language and pass the Localizer down through the request context:

var (
	supported = []language.Tag{language.English, language.Italian, language.German}
	matcher   = language.NewMatcher(supported)
)

type ctxKey struct{}

func withStrings(client *airstrings.Client, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		_, i := language.MatchStrings(matcher, r.URL.Query().Get("lang"), r.Header.Get("Accept-Language"))
		l := client.Localizer(supported[i].String(), currentUserID(r))
		next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, l)))
	})
}

func handler(w http.ResponseWriter, r *http.Request) {
	l := r.Context().Value(ctxKey{}).(*airstrings.Localizer)
	fmt.Fprintln(w, l.T("greeting"))
}

Methods

*Client:

  • Localizer(locale, assignmentID string) *Localizer: reader pinned to the current revision of locale.
  • WaitReady(ctx) error: blocks until the first fetch of every locale finished (successfully or not) or the client is closed.
  • Refresh(ctx) error: fetches all locales now. Honors ETag/304. Safe to call concurrently.
  • Revision(locale string) int64: applied revision, 0 when nothing is loaded.
  • Close() error: stops background refresh. Idempotent. Reads keep working.

*Localizer:

  • T(key string) string: raw string for key (ICU patterns unformatted), the selected variant when an assignment is set, or key as fallback.
  • Format(key string, args map[string]any) string: formats an ICU MessageFormat string.

Errors

Everything after New is reported to OnError (and the Logger), and Refresh returns the same errors joined. Test with errors.Is:

OnError: func(err error) {
	switch {
	case errors.Is(err, airstrings.ErrSignature), errors.Is(err, airstrings.ErrUnknownKeyID):
		alert("AirStrings bundle rejected", err)
	case errors.Is(err, airstrings.ErrHTTPStatus):
		log.Printf("CDN error: %v", err)
	default:
		log.Printf("airstrings: %v", err)
	}
},

ErrSignature, ErrUnknownKeyID, ErrUnsupportedFormatVersion, ErrInvalidSignatureEncoding, ErrInvalidKeyIDEncoding (returned by New), ErrBundleDecode, ErrProjectMismatch, ErrLocaleMismatch, and ErrHTTPStatus are exported. A rejected bundle never replaces the strings already being served.

String variants

String variants are A/B experiments: each variant is selected deterministically from a stable assignment id, with no server round-trip. Pass the assignment id (for example the user id) to Localizer and forward exposures to your own analytics:

client, err := airstrings.New(airstrings.Config{
	// ...
	OnExposure: func(e airstrings.Exposure) {
		analytics.Track("experiment_exposure", e.Key, e.ExperimentID, e.Variant, e.Locale, e.AssignmentID)
	},
})

l := client.Localizer("en", user.ID)
l.T("checkout.cta")

OnExposure is called on its own goroutine, never inside the read, once per distinct (key, experiment, variant, assignment) for the lifetime of the Client. On very long-running servers the dedupe set is reset after 100,000 entries, so an exposure may be delivered again after that.

An empty assignment id serves base values. Experiment content is Ed25519-verified via a separate experiments_signature and soft-fails to base values if verification fails: variants are never served unverified. Available since Go SDK 1.0.0.

See String Variants for how selection, bucketing, and exposure work, and managing experiments from the CLI to create one.

ICU formatting

Every string has a format: "text" (plain) or "icu" (ICU MessageFormat). Use Format(key, args) to format ICU strings:

l.Format("items.count", map[string]any{"count": 3})

Supported: arguments, number (plain, integer, percent), plural, select, and selectordinal, with CLDR plural rules for every locale. Numeric arguments must be Go numeric types (int, float64, …).

text strings are returned as-is (arguments ignored). Format never panics: a missing or mistyped argument, or an unsupported pattern (date, time, skeletons, spellout), returns the raw pattern; an unknown key returns key. Pattern syntax: ICU by example.

Caching & offline

  1. New loads the disk cache and seed synchronously, then fetches every locale from the CDN in the background.
  2. Every bundle is Ed25519-signed. Verification is mandatory: an invalid signature is rejected and never served.
  3. Cached and seeded bundles are re-verified on every load (defense in depth), and must match the configured project and locale.
  4. Anti-downgrade protection: a newer revision is never replaced by an older one for the same locale.
  5. Bundles refresh in the background every RefreshInterval (5 minutes by default), using ETag / If-None-Match to avoid re-downloading unchanged bundles.
  6. Failures never panic. The Client keeps the last good bundle and reports to OnError. With no cache, no seed, and no network, key names are returned as a fallback.

Without CacheDir, bundles live in memory for the process lifetime. With it, the last verified bundle of each locale is written atomically to {CacheDir}/{project}/{environment}/{locale}/bundle.json, so a restart without network still serves the latest strings; an invalid entry is deleted.

Multiple PublicKeys support rotation; see Key rotation.

Bundled fallback

Ship published, signed bundles inside your binary so a cold start with no network serves real strings instead of key names. Pull them with the CLI, commit the airstrings/bundles/ folder, and embed it:

airstrings bundles pull
//go:embed airstrings/bundles/*.json
var bundles embed.FS

seed, _ := fs.Sub(bundles, "airstrings/bundles")
client, err := airstrings.New(airstrings.Config{
	// ...
	Seed: seed,
})

Without Seed, the SDK reads ./airstrings/bundles/ relative to the working directory when it exists. Set DisableSeed: true to turn seeding off. The highest verified revision wins between disk cache and seed (ties go to the cache). See Offline & fallback.

Troubleshooting

SymptomLikely causeFix
go get fails with a Go version errorToolchain older than Go 1.26Upgrade to Go 1.26+.
T("key") returns the key nameNo bundle has loaded yet, the locale is not in Config.Locales, or no bundle is published for this localeCall WaitReady before serving; pass a locale exactly as listed in Locales; confirm OrganizationID, ProjectID, and EnvironmentID.
Format returns the raw patternMissing or mistyped argument, or an unsupported pattern (date, time, skeletons)Pass every argument, numeric ones as Go numeric types; avoid unsupported patterns.
Strings never update after publishingWrong EnvironmentID, or the next background refresh hasn't run yetVerify the environment ID; call Refresh(ctx) or wait for RefreshInterval.
OnError receives ErrUnknownKeyID or ErrSignaturePublicKeys does not contain the key that signed the bundleCopy the environment's public key from Project Settings > SDK Configuration. After rotation, include both old and new keys.

On this page