Azbox publishes a small Node.js client, azbox-node. This guide covers what it does, what it does not, and how to build a working translation layer on top of it.
Updated September 2026. Versions 0.1.0 and 0.1.1 of
azbox-nodedid not work against the API. Use 0.2.0 or newer; the examples below are written for it.
What the client does, and what it does not
The package is one class:
export declare class AzboxClient {
constructor(options: { apiKey: string; projectId: string; language: string; baseUrl?: string });
getTranslations(options?: { afterUpdatedAt?: Date }): Promise<Record<string, string>>;
getKeywords(options?: { afterUpdatedAt?: Date }): Promise<AzboxKeyword[]>;
}
In particular:
- It only reads. There is no method to push or upload strings. Keywords are created in the Azbox panel, either one by one with Add Keyword or by importing a file (ARB, JSON/i18next, .xcstrings, XML, XLSX, CSV, YAML or PHP). See the Azbox quickstart.
- It fetches one language at a time.
languageis set on the client, not per call. For several languages, create one client per language. - There is no
t()function. You get a dictionary and do the lookup yourself. That is what most of this guide is about.
Prerequisites
- Node.js 18 or newer — the client uses the global
fetch - An Azbox project, with its Project ID and an API key (panel → Settings → API keys)
- Some keywords already added in the panel
Step 1: Install
npm install azbox-node
Step 2: Fetch the translations
import { AzboxClient } from "azbox-node";
const client = new AzboxClient({
apiKey: process.env.AZBOX_API_KEY,
projectId: process.env.AZBOX_PROJECT_ID,
language: "ES",
});
const translations = await client.getTranslations();
// { "home.title": "Bienvenido", … }
Under the hood this is a single call to GET https://api.azbox.io/v1/projects/:projectId/keywords. Keywords with no text yet in that language are left out. getKeywords() returns the raw response instead, [{ id, data: { keyword, translation, … } }], where the key is data.keyword and id is an internal identifier.
Step 3: Keep a dictionary per language
Calling the API per string would mean one HTTP request per string, so fetch once at startup:
// i18n.ts
import { AzboxClient } from "azbox-node";
type Dictionary = Record<string, string>;
const dictionaries = new Map<string, Dictionary>();
function clientFor(language: string) {
return new AzboxClient({
apiKey: process.env.AZBOX_API_KEY!,
projectId: process.env.AZBOX_PROJECT_ID!,
language,
});
}
export async function loadLanguage(language: string): Promise<Dictionary> {
const dict = await clientFor(language).getTranslations();
dictionaries.set(language, dict);
return dict;
}
export function t(language: string, key: string, params?: Record<string, string | number>) {
const value = dictionaries.get(language)?.[key];
if (value === undefined) return key; // fall back to the key, never to an empty string
if (!params) return value;
return value.replace(/\{(\w+)\}/g, (m, p) => String(params[p] ?? m));
}
Interpolation is your responsibility: the API returns the string as stored, placeholders included.
Step 4: Load at startup, not per request
const LANGUAGES = ["EN", "ES", "FR"];
await Promise.all(LANGUAGES.map(loadLanguage));
app.listen(3000);
If a language fails to load, decide deliberately whether to start anyway with a fallback or to fail loudly. Starting with a half-empty dictionary and no log is how you end up shipping raw keys to users.
Step 5: Refresh only what changed
Both methods accept afterUpdatedAt, which maps to the afterUpdatedAtStr query parameter. Use it to poll for changes without refetching everything:
const lastSync = new Map<string, Date>();
export async function refresh(language: string) {
const since = lastSync.get(language);
const startedAt = new Date();
const changed = await clientFor(language).getTranslations(since ? { afterUpdatedAt: since } : {});
dictionaries.set(language, { ...dictionaries.get(language), ...changed });
lastSync.set(language, startedAt);
return Object.keys(changed).length;
}
The sync time is taken before the request, so a string edited while the request is in flight is picked up on the next refresh instead of being skipped.
This is what makes over-the-air updates work on the server: correct a string in the panel, and the next refresh picks it up without a deploy.
Step 6: Express middleware
import express from "express";
import { t } from "./i18n";
const app = express();
app.use((req, res, next) => {
const header = req.headers["accept-language"] ?? "";
const lang = String(header).slice(0, 2).toUpperCase();
req.language = ["EN", "ES", "FR"].includes(lang) ? lang : "EN";
next();
});
app.get("/api/welcome", (req, res) => {
res.json({ message: t(req.language, "home.title", { name: "Ada" }) });
});
Error handling
The client throws AzboxError, with status (401 for a wrong or revoked key, 403 for a key without access to the project) and the API’s detail. A project with no keywords yet is not an error: you get an empty result. Treat a failed refresh as non-fatal — you already have the previous dictionary in memory:
setInterval(() => {
refresh("ES").catch((err) => console.error("[i18n] refresh failed:", err.status, err.message));
}, 5 * 60 * 1000);
Where the strings come from
Worth repeating, because it is the part people expect to automate and cannot: keywords are created in the panel, not from code. The usual flow is to import your existing en.json (or ARB, or XML) once, translate in Azbox, and let your application pull the results with the client above. The API is read-only for keywords, so there is no endpoint to create them programmatically.
If what you need is translation files on disk rather than a dictionary in memory, the AZbox CLI writes them without any code.