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
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
format_version | integer | yes | Currently 1. Clients MUST reject unknown versions | Envelope format version |
project_id | string | yes | Pattern ^proj_[a-z0-9]{12,32}$ | Project this bundle belongs to |
locale | string | yes | BCP 47 language tag (e.g. en-US, ja, pt-BR) | Locale of the strings in this bundle |
revision | integer | yes | Monotonically increasing per project and locale, starting at 1 | Lets clients detect stale bundles |
created_at | string | yes | RFC 3339, always UTC (Z suffix) | When the bundle was compiled |
key_id | string | yes | Standard base64 Ed25519 public key (44 characters for 32 bytes) | The public key that produced the signature |
signature | string | yes | Base64url (RFC 4648 §5, no padding), 86 characters (64 bytes) | Ed25519 signature over the canonical signed content |
strings | object | yes | Keys 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 keys | Map of string keys to localized values |
experiments_signature | string | no | Present exactly when at least one entry carries an experiment block. Base64url, no padding | Independent 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.
- Build a JSON object with these fields from the bundle, in this exact order:
format_version,project_id,locale,revision,created_at,strings. - Serialize it to canonical JSON (rules below).
- 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
- No whitespace between tokens.
- Nested object keys sorted lexicographically by Unicode code point, recursively. The six top-level fields keep the fixed order above.
- No trailing commas.
- Numbers without leading zeros or trailing decimal points. Integers as integers (no
.0). - Strings escaped per RFC 8259, escaping only
",\, and control characters U+0000 to U+001F. No HTML escaping. - 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
- Parse the JSON bundle.
- Read
key_id. If it does not match one of the client's configured public keys, reject the bundle (hard error). - Rebuild the canonical signed content.
- Base64url-decode
signatureand verify it against the canonical bytes with the matching public key. On failure, reject the bundle (hard error). - If
format_versionis not a recognized version, reject the bundle (hard error). - 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" }
}
}valueis the control.variantsmaps each non-control variant name to this locale's value and never contains"control".allocationcontains"control"plus every variant name. Values are non-negative integers summing to exactly100.- Variant names match
^[a-z0-9_]{1,64}$;controlis 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. experimentsmaps each string key with a running experiment to exactlyallocation,id,variants(in that sorted order), wherevariantsholds 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_idas 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)
- Verify the main
signatureas above (hard error on failure). - If no entry carries an
experimentblock, stop. - Otherwise require
experiments_signature, rebuild its canonical content from the parsed fields, and verify it with the same configured key set. - 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 (controlwrites 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:
- A new keypair is generated and its public key registered.
- App releases ship with both the old and the new public key.
- Once enough clients have updated, signing switches to the new key. Clients holding both keys verify either.
- The old key is removed from client configuration.
CDN delivery
URL structure
https://cdn.airstrings.com/{org_id}/{project_id}/{env_id}/{locale}/bundle.jsonThe 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=86400max-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, andLast-Modifiedas on the CDN. - Conditional requests:
If-None-Matchcompared weakly, returning304 Not Modifiedon a match. - Errors: unknown bundle or invalid path segment returns
404; storage errors return502or503. - Methods:
GETonly. Query strings are ignored. - CORS:
Access-Control-Allow-Origin: *,If-None-MatchandIf-Modified-Sinceallowed,ETagexposed.
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
| Constraint | Limit |
|---|---|
| String key | 1 to 256 characters from [a-zA-Z0-9_.] |
| String value | 0 to 10,000 UTF-8 characters |
| Keys per bundle | 10,000 |
| Serialized bundle | 5 MB |
| Locale tag | Valid 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:
| Key | Value | Format |
|---|---|---|
onboarding.welcome_title | Welcome to Acme | text |
onboarding.welcome_body | Get started in minutes. | text |
settings.language | Language | text |
items.count | {count, plural, one {# item} other {# items}} | icu |
error.network | Something 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.jsonVerification: 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
| Decision | Rationale |
|---|---|
String values are {value, format} objects | Each string carries its format (text or icu), so clients know how to render it without a second lookup |
Flat strings map | No ambiguity about key paths; apps can still use dot-notation names |
| Metadata in signed content | Prevents substitution and downgrade attacks |
| Base64url signature | URL-safe, no padding |
revision instead of a content hash | Simple monotonic ordering; ETags cover content-based caching |
| One bundle per locale | Each locale is cached independently; clients fetch only what they need |
{org_id}/{project_id}/{env_id}/{locale} path | Tenant and environment isolation in the delivery path |
Related: Bundled Fallback, Bundles, Signing, and CDN.
React Native
A complete React Native integration with a useSyncExternalStore hook, AsyncStorage caching, and offline bundled fallback seeded via Metro require.
Bundled Fallback
Specification for shipping published, signed bundles inside an app: seed directory layout, manifest, verification, anti-downgrade rules, platform packaging, and airstrings bundles pull.