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@latestSetup
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)| Field | Type | Required | Description |
|---|---|---|---|
OrganizationID | string | yes | Your AirStrings organization ID. |
ProjectID | string | yes | Project ID. |
EnvironmentID | string | yes | Environment ID (e.g. production, staging). |
PublicKeys | []string | yes | One or more standard-base64 Ed25519 public keys. Multiple keys supported for rotation. |
Locales | []string | yes | BCP-47 locales to load (e.g. "en", "pt-BR"). |
APIBaseURL | string | no | API base URL. Defaults to https://api.airstrings.com. |
RefreshInterval | time.Duration | no | Background refresh period. Default 5 minutes; negative disables background refresh (the first fetch still happens). |
CacheDir | string | no | Enables the disk cache. Empty keeps bundles in memory only. |
Seed | fs.FS | no | Bundled fallback files named {locale}.json. Default: ./airstrings/bundles if present. |
DisableSeed | bool | no | Turns seeding off. |
HTTPClient | *http.Client | no | Custom transport (proxies, tracing). The SDK applies its own timeouts. |
Logger | *slog.Logger | no | Structured logs. Default: discarded. |
OnUpdate | func(locale string, revision int64) | no | Called after a locale moves to a new revision. |
OnError | func(error) | no | Called for every failure: rejected bundles, seed and cache errors, network errors. |
OnExposure | func(Exposure) | no | Experiment 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 oflocale.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,0when nothing is loaded.Close() error: stops background refresh. Idempotent. Reads keep working.
*Localizer:
T(key string) string: raw string forkey(ICU patterns unformatted), the selected variant when an assignment is set, orkeyas 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
Newloads the disk cache and seed synchronously, then fetches every locale from the CDN in the background.- Every bundle is Ed25519-signed. Verification is mandatory: an invalid signature is rejected and never served.
- Cached and seeded bundles are re-verified on every load (defense in depth), and must match the configured project and locale.
- Anti-downgrade protection: a newer revision is never replaced by an older one for the same locale.
- Bundles refresh in the background every
RefreshInterval(5 minutes by default), usingETag/If-None-Matchto avoid re-downloading unchanged bundles. - Failures never panic. The
Clientkeeps the last good bundle and reports toOnError. 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
| Symptom | Likely cause | Fix |
|---|---|---|
go get fails with a Go version error | Toolchain older than Go 1.26 | Upgrade to Go 1.26+. |
T("key") returns the key name | No bundle has loaded yet, the locale is not in Config.Locales, or no bundle is published for this locale | Call WaitReady before serving; pass a locale exactly as listed in Locales; confirm OrganizationID, ProjectID, and EnvironmentID. |
Format returns the raw pattern | Missing 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 publishing | Wrong EnvironmentID, or the next background refresh hasn't run yet | Verify the environment ID; call Refresh(ctx) or wait for RefreshInterval. |
OnError receives ErrUnknownKeyID or ErrSignature | PublicKeys does not contain the key that signed the bundle | Copy the environment's public key from Project Settings > SDK Configuration. After rotation, include both old and new keys. |