From your backend
A server that already keeps its own strings does not need the SDK, and does not need a column per language. Name each string once, ask for every language in one request, and read the answers back under the names you gave.
The usual shape of this problem in a backend is title_en, title_ar, title_fr — a migration for every language and a NULL the day someone adds one. Here the row is one sentence in one language, and the answer lives in a table keyed by that sentence. Your schema never changes when the market does.
Four pieces
keystring- Your name for the string, e.g. checkout.title. Optional; leave it out and the string still translates, filed under its source text.
targets[]string[]- Up to 8 languages in one batch call. The answer comes back as results keyed by each canonical tag.
GET /v1/strings?by=keymap- Every named string in one language as a single object — the warm-up call for a server-side dictionary.
GET /v1/languagesdeclaration- What the project is configured to speak, so the list is editable from the dashboard instead of hardcoded in your deploy.
One call, every language
curl -s "$SIYAK_HOST/v1/translate/batch" \
-H "authorization: Bearer $SIYAK_KEY" \
-H "content-type: application/json" \
-d '{
"targets": ["ar", "ar-EG", "fr", "zh-Hans"],
"items": [
{ "key": "checkout.title", "text": "Complete your order", "context": "screen:checkout" },
{ "key": "checkout.button", "text": "Pay now", "context": "screen:checkout" },
{ "key": "cart.empty", "text": "Your basket is empty" }
]
}'
# → {"items":[{"index":0,"key":"checkout.title","input":"Complete your order",
# "results":{"ar":{"output":"أكمل طلبك","origin":"approved","status":"approved"},
# "ar-EG":{…},"fr":{…},"zh-Hans":{…}}}, …],
# "targets":["ar","ar-EG","fr","zh-Hans"],
# "usage":{"used":5820,"limit":15000,"month":"2026-10"}}The languages inside one item go out together; the items are answered in order, so index 0 is always your first row. Four new languages bill four times the source characters — that is the whole cost model, and it is why this is a boot-time call, not a per-request one.
Node / Express
// Run once at boot. After this, serving a translated API costs no network call.
const LANGS = ['ar', 'ar-EG', 'fr', 'zh-Hans'];
async function warm(lang) {
const r = await fetch(`${process.env.SIYAK_URL}/v1/strings?target=${lang}&by=key`, {
headers: { authorization: `Bearer ${process.env.SIYAK_KEY}` },
});
if (!r.ok) throw new Error(`siyak ${r.status}`);
return (await r.json()).strings; // { "checkout.title": "أكمل طلبك", … }
}
export const dict = Object.fromEntries(
await Promise.all(LANGS.map(async (lang) => [lang, await warm(lang)])),
);
// The source text is the fallback, so an untranslated string is a slow
// release, never a blank field.
export const t = (key, lang, source) => dict[lang]?.[key] ?? source;import { t } from '../siyak.js';
app.get('/api/checkout', (req, res) => {
const lang = req.query.lang ?? 'ar';
res.json({
title: t('checkout.title', lang, 'Complete your order'),
cta: t('checkout.button', lang, 'Pay now'),
});
});A string nobody has named yet simply is not in the map, so it answers in your source language while the Inbox fills. Nothing needs to be pre-registered.
Laravel
<?php
namespace App\Support;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
class Siyak
{
/** @param array<string,string> $keys ['checkout.title' => 'Complete your order', …] */
public static function warm(array $keys, array $targets = ['ar', 'ar-EG', 'fr']): array
{
$body = Http::withToken(config('services.siyak.key'))
->post(rtrim(config('services.siyak.url'), '/').'/v1/translate/batch', [
'targets' => $targets,
'items' => collect($keys)
->map(fn ($text, $key) => ['key' => $key, 'text' => $text])
->values()->all(),
])->throw()->json();
$out = [];
foreach ($body['items'] as $item) {
foreach ($item['results'] as $lang => $answer) {
$out[$lang][$item['key']] = $answer['output'] ?? $keys[$item['key']];
}
}
return $out;
}
public static function get(string $key, string $locale, string $source): string
{
return Cache::remember('siyak', now()->addDay(), fn () => self::warm(self::$strings))[$locale][$key] ?? $source;
}
}Blade calls it the same way you would call trans(): {{ Siyak::get('checkout.title', app()->getLocale(), 'Complete your order') }}. No extra column, and no lang/ar.php file to keep in step.
Django
import httpx, os
API, KEY = os.environ["SIYAK_URL"], os.environ["SIYAK_KEY"]
def warm(keys: dict[str, str], targets: list[str]) -> dict[str, dict[str, str]]:
"""keys = {"checkout.title": "Complete your order", ...}"""
resp = httpx.post(
f"{API}/v1/translate/batch",
headers={"authorization": f"Bearer {KEY}"},
json={
"targets": targets,
"items": [{"key": k, "text": v} for k, v in keys.items()],
},
timeout=30,
)
resp.raise_for_status()
out = {t: {} for t in targets}
for row in resp.json()["items"]:
for lang, answer in row["results"].items():
out[lang][row["key"]] = answer["output"] or keys[row["key"]]
return out
def t(key: str, lang: str, source: str) -> str:
return cache.get("siyak", {}).get(lang, {}).get(key, source)A management command or a ready() hook calls warm() once per deploy; the request path only ever reads the cache.
What the key does not do
- It is a label, not an identity. A string is decided by sha256(source text | language | context) — so the same sentence under two keys is one row wearing two names, and the second name simply moves onto the row the first already sits on.
- It is not required. Never send it if your source text is already your key — the map by source is the same call with by=source.
- Rewriting the source under an existing key is a conflict, not a silent move: the old row still holds the name, so the call answers 409 key_conflict and bills nothing. Move the name in the Inbox, or send a new key.
- by=key answers only strings someone has named — a call carrying key, an import, or a name set in the Inbox. Unnamed work is invisible there on purpose, and still resolves through /v1/translate.