Specifications

Bundle Format

The signed bundle specification: envelope fields, canonical JSON, Ed25519 signature construction, verification procedure, experiments, and CDN delivery.

This is the specification of the string bundle every AirStrings SDK fetches and verifies. The platform produces bundles in this format; any client that implements it can consume them, and anyone can independently verify a published bundle.

Version: format_version 1.

Overview

AirStrings delivers localized strings as signed JSON bundles over a CDN. One bundle holds one locale of one environment. Every bundle is immutable once published, signed with Ed25519, and verified client-side before use.

Bundle envelope

The bundle is a single JSON object with metadata, signature, and payload.

{
  "format_version": 1,
  "project_id": "proj_a1b2c3d4e5f6",
  "locale": "en-US",
  "revision": 42,
  "created_at": "2026-02-25T14:30:00Z",
  "key_id": "BASE64_PUBLIC_KEY",
  "signature": "base64url-encoded-ed25519-signature",
  "strings": {
    "onboarding.welcome_title": { "value": "Welcome to Acme", "format": "text" },
    "onboarding.welcome_body": { "value": "Get started in minutes.", "format": "text" },
    "settings.language": { "value": "Language", "format": "text" },
    "items.count": { "value": "{count, plural, one {# item} other {# items}}", "format": "icu" },
    "error.network": { "value": "Something went wrong. Please try again.", "format": "text" }
  }
}

Fields

FieldTypeRequiredConstraintsDescription
format_versionintegeryesCurrently 1. Clients MUST reject unknown versionsEnvelope format version
project_idstringyesPattern ^proj_[a-z0-9]{12,32}$Project this bundle belongs to
localestringyesBCP 47 language tag (e.g. en-US, ja, pt-BR)Locale of the strings in this bundle
revisionintegeryesMonotonically increasing per project and locale, starting at 1Lets clients detect stale bundles
created_atstringyesRFC 3339, always UTC (Z suffix)When the bundle was compiled
key_idstringyesStandard base64 Ed25519 public key (44 characters for 32 bytes)The public key that produced the signature
signaturestringyesBase64url (RFC 4648 §5, no padding), 86 characters (64 bytes)Ed25519 signature over the canonical signed content
stringsobjectyesKeys match ^[a-zA-Z0-9_.]{1,256}$. Values are objects with value (UTF-8, max 10,000 chars), format ("text" or "icu"), and an optional experiment object. Max 10,000 keysMap of string keys to localized values
experiments_signaturestringnoPresent exactly when at least one entry carries an experiment block. Base64url, no paddingIndependent signature over the running experiments (see Experiments)

The top-level object MUST contain every required field. experiments_signature is the only optional top-level field. Clients SHOULD ignore unknown top-level fields for forward compatibility, but MUST NOT ignore an unknown format_version.

Signature scheme

Algorithm

Ed25519 (RFC 8032). 32-byte public keys, 64-byte signatures.

Signed content

The signature covers a canonical byte string built from the bundle metadata and strings, so it binds every critical field, not just the string content.

  1. Build a JSON object with these fields from the bundle, in this exact order: format_version, project_id, locale, revision, created_at, strings.
  2. Serialize it to canonical JSON (rules below).
  3. Sign the resulting UTF-8 bytes with Ed25519.
{"format_version":1,"project_id":"proj_a1b2c3d4e5f6","locale":"en-US","revision":42,"created_at":"2026-02-25T14:30:00Z","strings":{"error.network":{"format":"text","value":"Something went wrong. Please try again."},"items.count":{"format":"icu","value":"{count, plural, one {# item} other {# items}}"},"onboarding.welcome_body":{"format":"text","value":"Get started in minutes."},"onboarding.welcome_title":{"format":"text","value":"Welcome to Acme"},"settings.language":{"format":"text","value":"Language"}}}

Canonical JSON rules

  1. No whitespace between tokens.
  2. Nested object keys sorted lexicographically by Unicode code point, recursively. The six top-level fields keep the fixed order above.
  3. No trailing commas.
  4. Numbers without leading zeros or trailing decimal points. Integers as integers (no .0).
  5. Strings escaped per RFC 8259, escaping only ", \, and control characters U+0000 to U+001F. No HTML escaping.
  6. UTF-8, no BOM.

These rules follow the approach of JCS (RFC 8785); a simple sorted-key serializer satisfies them.

Why metadata is signed

Signing format_version, project_id, locale, revision, and created_at prevents:

  • Bundle substitution: swapping strings between projects or locales.
  • Downgrade attacks: serving an old revision as current.
  • Format confusion: reinterpreting content under a different format version.

key_id and signature are excluded from the signed content: the signature cannot cover itself, and the key is authenticated implicitly by a successful verification.

The environment is not part of the signed content. Environment binding comes from the delivery path at fetch time; see CDN delivery.

Verification procedure

  1. Parse the JSON bundle.
  2. Read key_id. If it does not match one of the client's configured public keys, reject the bundle (hard error).
  3. Rebuild the canonical signed content.
  4. Base64url-decode signature and verify it against the canonical bytes with the matching public key. On failure, reject the bundle (hard error).
  5. If format_version is not a recognized version, reject the bundle (hard error).
  6. The bundle is trusted: use strings.

AirStrings SDKs treat every verification failure as a hard error. There is no unsigned delivery path and no "skip verification" option.

Experiments

A string may carry an A/B experiment: one or more non-control variants alongside the base value (the control) and a traffic allocation across all buckets. Experiments ship inside the bundle so clients can assign buckets without an extra request.

Entry shape

The experiment nests inside the string entry:

"checkout.cta": {
  "value": "Start checkout",
  "format": "text",
  "experiment": {
    "id": "exp_a1b2c3d4e5f6",
    "allocation": { "control": 50, "variant_a": 25, "variant_b": 25 },
    "variants": { "variant_a": "Continue", "variant_b": "Buy now" }
  }
}
  • value is the control. variants maps each non-control variant name to this locale's value and never contains "control".
  • allocation contains "control" plus every variant name. Values are non-negative integers summing to exactly 100.
  • Variant names match ^[a-z0-9_]{1,64}$; control is reserved. At most 10 variants per experiment; each variant value is at most 10,000 characters.
  • Experiment IDs are exp_ followed by 12 hex characters.
  • Only running experiments are embedded. There is no status field: presence of the block means the experiment is live.

Per-locale omission

A locale's bundle carries the experiment block for a string only if every variant has a value for that locale. Otherwise the block is omitted and clients serve the control.

experiments_signature

When at least one entry carries an experiment block, the envelope gains an experiments_signature field. Bundles without experiments omit it, and format_version stays 1: the scheme is additive, and clients that predate experiments ignore both the blocks and the signature.

Its canonical content is built only from experiment data:

  • Top-level fields in fixed order: format_version, project_id, locale, revision, created_at, experiments.
  • experiments maps each string key with a running experiment to exactly allocation, id, variants (in that sorted order), where variants holds only this locale's variants.
  • All nested object keys sorted by Unicode code point, no whitespace, RFC 8259 escaping, bare integers, UTF-8 without BOM.
  • Signed with the same key and key_id as the main signature. Base64url, no padding.

A generic serializer that sorts the top-level keys produces mismatched signatures; keep the fixed top-level order. Reference fixture (byte-exact):

{"format_version":1,"project_id":"proj_x","locale":"en-US","revision":42,"created_at":"2026-07-14T10:00:00Z","experiments":{"checkout.cta":{"allocation":{"control":50,"variant_a":50},"id":"exp_a1b2c3d4e5f6","variants":{"variant_a":"Continue"}}}}

The main signature's canonical content MUST NEVER include experiment data. The two signatures are independent: adding experiments changes neither the main canonical content nor signature.

Experiment verification (soft-fail)

  1. Verify the main signature as above (hard error on failure).
  2. If no entry carries an experiment block, stop.
  3. Otherwise require experiments_signature, rebuild its canonical content from the parsed fields, and verify it with the same configured key set.
  4. On any failure (missing signature, mismatch, malformed block), ignore all experiment blocks and serve the base values. Never reject the bundle for an experiment problem.

Publish, stop, and promote

Bundles are immutable, so no experiment change reaches clients until the next publish of that locale:

  • Publish compiles the running experiments into the bundle and signs experiments_signature.
  • Promote writes the winning variant's values into the base values (control writes nothing) and republishes.
  • Stop does not republish. A stopped experiment is served until the next publish and cache expiry.

Keys and rotation

key_id is the standard base64 encoding of the Ed25519 public key, so it is self-describing: clients need no mapping from opaque identifiers to key material.

Clients are configured with one or more public keys:

let airStrings = AirStrings(configuration: .init(
    organizationId: "org_a1b2c3d4e5f6",
    projectId: "proj_a1b2c3d4e5f6",
    environmentId: "env_x1y2z3w4v5u6",
    publicKeys: ["BASE64_PUBLIC_KEY_01", "BASE64_PUBLIC_KEY_02"]
))

Rotation:

  1. A new keypair is generated and its public key registered.
  2. App releases ship with both the old and the new public key.
  3. Once enough clients have updated, signing switches to the new key. Clients holding both keys verify either.
  4. The old key is removed from client configuration.

CDN delivery

URL structure

https://cdn.airstrings.com/{org_id}/{project_id}/{env_id}/{locale}/bundle.json

The path binds the environment: a client fetches only from its configured environment's path.

Response headers

Content-Type: application/json; charset=utf-8
Cache-Control: public, max-age=300, s-maxage=86400, stale-while-revalidate=86400
  • max-age=300: clients see a new publish within about 5 minutes.
  • s-maxage=86400: edge cache lifetime.
  • stale-while-revalidate=86400: the edge serves stale while revalidating in the background.

The ETag is a quoted content hash of the stored object (e.g. "9b2cf535f27731c974343645a3985328"), possibly weakened to W/"…" when the response is compressed. Clients send it back verbatim as If-None-Match; a match returns 304 Not Modified. Clients MUST NOT parse it.

Fallback host

A second origin for the same bundles, used only after a CDN failure. It is advertised as fallback_base_url by GET https://api.airstrings.com/v1/sdk/bootstrap (default https://api.airstrings.com/v1/cdn).

  • Path: {fallback_base_url}/{org_id}/{project_id}/{env_id}/{locale}/bundle.json, identical to the CDN.
  • Body: byte-identical to the CDN object; verification is unchanged.
  • Headers: ETag, Cache-Control, Content-Type, and Last-Modified as on the CDN.
  • Conditional requests: If-None-Match compared weakly, returning 304 Not Modified on a match.
  • Errors: unknown bundle or invalid path segment returns 404; storage errors return 502 or 503.
  • Methods: GET only. Query strings are ignored.
  • CORS: Access-Control-Allow-Origin: *, If-None-Match and If-Modified-Since allowed, ETag exposed.

Versioning

format_version is currently 1. It increments only for breaking changes to the envelope. Backwards-compatible additions (new optional fields) do not increment it; clients ignore unknown fields.

revision increases monotonically per project and locale, incremented on every publish. Revision 0 is reserved; the first bundle is revision 1. Clients use it to refuse downgrades (never replace revision 42 with 41).

Size limits

ConstraintLimit
String key1 to 256 characters from [a-zA-Z0-9_.]
String value0 to 10,000 UTF-8 characters
Keys per bundle10,000
Serialized bundle5 MB
Locale tagValid BCP 47, max 35 characters

A publish that would exceed these limits is rejected with 422 Unprocessable Entity.

Complete example

Project proj_a1b2c3d4e5f6 has these en-US strings:

KeyValueFormat
onboarding.welcome_titleWelcome to Acmetext
onboarding.welcome_bodyGet started in minutes.text
settings.languageLanguagetext
items.count{count, plural, one {# item} other {# items}}icu
error.networkSomething went wrong. Please try again.text

Canonical signed content:

{"format_version":1,"project_id":"proj_a1b2c3d4e5f6","locale":"en-US","revision":42,"created_at":"2026-02-25T14:30:00Z","strings":{"error.network":{"format":"text","value":"Something went wrong. Please try again."},"items.count":{"format":"icu","value":"{count, plural, one {# item} other {# items}}"},"onboarding.welcome_body":{"format":"text","value":"Get started in minutes."},"onboarding.welcome_title":{"format":"text","value":"Welcome to Acme"},"settings.language":{"format":"text","value":"Language"}}}

Fetched from:

GET https://cdn.airstrings.com/org_a1b2c3d4e5f6/proj_a1b2c3d4e5f6/env_x1y2z3w4v5u6/en-US/bundle.json

Verification: match key_id against the configured keys, rebuild the canonical content above, base64url-decode signature to 64 bytes, verify with Ed25519, check format_version is 1, then accept strings.

Design decisions

DecisionRationale
String values are {value, format} objectsEach string carries its format (text or icu), so clients know how to render it without a second lookup
Flat strings mapNo ambiguity about key paths; apps can still use dot-notation names
Metadata in signed contentPrevents substitution and downgrade attacks
Base64url signatureURL-safe, no padding
revision instead of a content hashSimple monotonic ordering; ETags cover content-based caching
One bundle per localeEach locale is cached independently; clients fetch only what they need
{org_id}/{project_id}/{env_id}/{locale} pathTenant and environment isolation in the delivery path

Related: Bundled Fallback, Bundles, Signing, and CDN.

On this page