i18n Audit — API & tutorial API tokens Open the app

Drive i18n Audit from your own code

Everything the app does is one REST surface. Submit UI source code, get back the audit in the exact tagged-markdown contract the app renders — usable from CI, a pre-commit hook, or a localization pipeline.

Basics

Base URL: https://api.skillsafe.ai/v1/app-api. Requests and responses are JSON (UTF-8). Authentication is Authorization: Bearer <token> on every call except POST /guest. CORS is open, so browser calls work too.

Every response is an envelope: {"ok": true, "data": ...} on success, {"ok": false, "error": {"code", "message", "details"}} on failure.

HTTPerror.codeMeaning
401unauthorizedMissing, expired or revoked token.
402payment_requiredBalance below the run's minimum hold. Guests get this on /run unless sponsorship is enabled.
403forbiddenToken belongs to a different app slug.
404not_foundUnknown job id or endpoint.
422invalid_inputInput failed validation (not JSON, or too large).
429rate_limitedSlow down; retry after the Retry-After seconds.
5xxinternalPlatform-side failure; safe to retry with the same Idempotency-Key.

Billing: /guest, /me and /estimate are free. /run and /run-stream reserve the estimate's hold_credits up front and settle to the (usually much lower) charged_credits when the job finishes.

Step 0 — a tiny client helper

Every later step is one call through a helper like this. Set SKILLSAFE_TOKEN in your environment first — get one on the token page or with step 1.

BASE=https://api.skillsafe.ai/v1/app-api
# All later calls look like:
curl -s "$BASE/me" -H "Authorization: Bearer $SKILLSAFE_TOKEN"
import os, json, urllib.request

BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ["SKILLSAFE_TOKEN"]

def api(method, path, body=None):
    req = urllib.request.Request(BASE + path, method=method,
        data=json.dumps(body).encode() if body is not None else None,
        headers={"Authorization": "Bearer " + TOKEN,
                 "Content-Type": "application/json"})
    with urllib.request.urlopen(req) as r:
        out = json.load(r)
    if not out.get("ok"):
        raise RuntimeError(out["error"])
    return out["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // read from your environment or secret store

async function api(method, path, body) {
  const res = await fetch(BASE + path, {
    method,
    headers: { Authorization: "Bearer " + TOKEN,
               "Content-Type": "application/json" },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const out = await res.json();
  if (!out.ok) throw new Error(out.error.code + ": " + out.error.message);
  return out.data;
}
package main

import (
    "bytes"; "encoding/json"; "net/http"; "os"
)

const base = "https://api.skillsafe.ai/v1/app-api"

func api(method, path string, body any) (map[string]any, error) {
    var buf bytes.Buffer
    if body != nil { json.NewEncoder(&buf).Encode(body) }
    req, _ := http.NewRequest(method, base+path, &buf)
    req.Header.Set("Authorization", "Bearer "+os.Getenv("SKILLSAFE_TOKEN"))
    req.Header.Set("Content-Type", "application/json")
    res, err := http.DefaultClient.Do(req)
    if err != nil { return nil, err }
    defer res.Body.Close()
    var out struct{ Ok bool; Data map[string]any; Error map[string]any }
    json.NewDecoder(res.Body).Decode(&out)
    if !out.Ok { return nil, fmtError(out.Error) }
    return out.Data, nil
}
import java.net.URI;
import java.net.http.*;

class SkillSafe {
    static final String BASE = "https://api.skillsafe.ai/v1/app-api";
    static final String TOKEN = System.getenv("SKILLSAFE_TOKEN");
    static final HttpClient HTTP = HttpClient.newHttpClient();

    static String api(String method, String path, String jsonBody) throws Exception {
        var b = HttpRequest.newBuilder(URI.create(BASE + path))
            .header("Authorization", "Bearer " + TOKEN)
            .header("Content-Type", "application/json");
        b = jsonBody == null ? b.GET()
            : b.method(method, HttpRequest.BodyPublishers.ofString(jsonBody));
        return HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString()).body();
        // parse the {ok, data|error} envelope with your JSON library
    }
}
require "net/http"; require "json"

BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN")

def api(method, path, body = nil)
  uri = URI(BASE + path)
  req = Net::HTTP.const_get(method.capitalize).new(uri)
  req["Authorization"] = "Bearer #{TOKEN}"
  req["Content-Type"] = "application/json"
  req.body = body.to_json if body
  out = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
  raise out["error"].to_s unless out["ok"]
  out["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN");

function api(string $method, string $path, ?array $body = null) {
    global $TOKEN;
    $ch = curl_init(BASE . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => ["Authorization: Bearer $TOKEN",
                               "Content-Type: application/json"],
        CURLOPT_POSTFIELDS => $body === null ? null : json_encode($body),
    ]);
    $out = json_decode(curl_exec($ch), true);
    if (!($out["ok"] ?? false)) throw new Exception(json_encode($out["error"]));
    return $out["data"];
}
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;

class SkillSafe {
    const string Base = "https://api.skillsafe.ai/v1/app-api";
    static readonly string Token =
        Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN")!;
    static readonly HttpClient Http = new();

    static async Task<JsonElement> Api(HttpMethod method, string path, object? body = null) {
        var req = new HttpRequestMessage(method, Base + path);
        req.Headers.Add("Authorization", "Bearer " + Token);
        if (body != null) req.Content = JsonContent.Create(body);
        var doc = JsonDocument.Parse(await (await Http.SendAsync(req)).Content.ReadAsStringAsync());
        if (!doc.RootElement.GetProperty("ok").GetBoolean())
            throw new Exception(doc.RootElement.GetProperty("error").ToString());
        return doc.RootElement.GetProperty("data");
    }
}

Step 1 — get a token

POST /guest

The easiest path is the token page — it shows the token this browser already uses (personal after SSO sign-in, guest otherwise) with one-click copy. Scripted guest tokens come from POST /guest with the app slug; guests can call /me and /estimate, and paid runs need a personal token (or sponsorship, which this app leaves off).

curl -s https://api.skillsafe.ai/v1/app-api/guest \
  -H "Content-Type: application/json" \
  -d '{"slug":"i18n-audit"}'
# -> {"ok":true,"data":{"token":"gst_...","guest_id":"..."}}
data = api("POST", "/guest", {"slug": "i18n-audit"})
print(data["token"])   # save it as SKILLSAFE_TOKEN
const { token } = await api("POST", "/guest", { slug: "i18n-audit" });
// save it; guests can /me and /estimate
data, err := api("POST", "/guest", map[string]any{"slug": "i18n-audit"})
if err != nil { panic(err) }
token := data["token"].(string)
String body = "{\"slug\":\"i18n-audit\"}";
String resp = SkillSafe.api("POST", "/guest", body);
// parse resp -> data.token
data = api("POST", "/guest", { slug: "i18n-audit" })
puts data["token"]
$data = api("POST", "/guest", ["slug" => "i18n-audit"]);
echo $data["token"];
var data = await SkillSafe.Api(HttpMethod.Post, "/guest",
    new { slug = "i18n-audit" });
var token = data.GetProperty("token").GetString();

Step 2 — who am I, and what can I spend?

GET /me

Returns subject_type (user or guest), subject_id and credits (1 credit = $0.0001). The app compares this balance against the estimate's hold before enabling the run button — do the same before submitting a run.

curl -s "$BASE/me" -H "Authorization: Bearer $SKILLSAFE_TOKEN"
me = api("GET", "/me")
print(me["subject_type"], me["credits"])
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil)
fmt.Println(me["subject_type"], me["credits"])
String me = SkillSafe.api("GET", "/me", null);
me = api("GET", "/me")
puts me["credits"]
$me = api("GET", "/me");
echo $me["credits"];
var me = await SkillSafe.Api(HttpMethod.Get, "/me");
Console.WriteLine(me.GetProperty("credits"));

Step 3 — the input shape, and what it will cost

POST /estimate

The input object is the same for /estimate, /run and /run-stream — exactly what the app submits:

FieldTypeMeaning
codestring, requiredThe UI source. Concatenate several files with // file: path marker lines. The app clips inputs over 48,000 chars by dropping the middle on line boundaries with a [... middle truncated ...] marker — do the same for huge inputs.
frameworkstring"React", "Vue", "Angular", "Svelte", "Plain JS", "HTML", "Other/mixed" or "" (the audit trusts the code over the claim).
localesstringTarget locales, e.g. "de, fr, ar". An RTL target weights the layout checks.
notesstringExisting i18n setup, key-naming conventions, constraints.
scanobjectOptional prescan counts (the app sends its in-browser pattern counts as untrusted hints). Omit it from scripts.
retry_notestringOnly for a re-run after a malformed reply; restates the required shape.

/estimate is free, creates no job, and returns hold_credits (the reserve, priced at the full output cap), min_credits, model and model_alias. Treat hold_credits as the reservation, never the price.

curl -s "$BASE/estimate" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"code":"// file: App.jsx\nexport const T = () => <h1>Welcome back</h1>;","framework":"React","locales":"de","notes":""}'
input_obj = {
    "code": open("src/App.jsx", encoding="utf-8").read(),
    "framework": "React",
    "locales": "de, fr, ar",
    "notes": "react-i18next, keys like page.section.label",
}
est = api("POST", "/estimate", input_obj)
print(est["hold_credits"], est["model"])
const input = {
  code: codeText,          // your source, with // file: markers
  framework: "React",
  locales: "de, fr, ar",
  notes: "",
};
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.model);
input := map[string]any{"code": code, "framework": "React",
    "locales": "de", "notes": ""}
est, _ := api("POST", "/estimate", input)
fmt.Println(est["hold_credits"])
String input = "{\"code\":\"...\",\"framework\":\"React\",\"locales\":\"de\",\"notes\":\"\"}";
String est = SkillSafe.api("POST", "/estimate", input);
input = { code: File.read("src/App.jsx"), framework: "React",
          locales: "de", notes: "" }
est = api("POST", "/estimate", input)
puts est["hold_credits"]
$input = ["code" => file_get_contents("src/App.jsx"),
          "framework" => "React", "locales" => "de", "notes" => ""];
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"];
var input = new { code = code, framework = "React",
                  locales = "de", notes = "" };
var est = await SkillSafe.Api(HttpMethod.Post, "/estimate", input);
Console.WriteLine(est.GetProperty("hold_credits"));

Step 4 — run the audit and poll

POST /run GET /jobs/{job_id}

Send the same input object. Always pass an Idempotency-Key header so a network retry of the same request can never double-bill. The app builds it as i18n-audit-{content hash}-{per-gesture nonce}, plus a lane suffix. Two rules follow, and both matter: keep the key stable while you are retrying one request over a flaky connection, and make it different for anything you intend to be a new run. A key that is merely a hash plus an attempt counter fails the second rule — the counter restarts, the platform replays the earlier result with deduped set, and you can never get a fresh answer for the same bytes. /run may resolve synchronously (the reply carries output) or return a queued job — poll /jobs/{id} until status is succeeded or failed. The result carries charged_credits (the actual cost) and truncated (true when the output was cut by a low balance).

The audit text is in output.output and follows the app's exact contract: four tag lines — VERDICT: (one of Ready for localization, Minor gaps, Needs extraction work, Not auditable), SCORE: 0–30, CONFIDENCE: 0–100, SUMMARY: — then six ## sections in order: Score breakdown (six category | X/Y | found | change bullets, maxima 8/5/5/4/4/4), Priority fixes, Extraction plan (`key` | "source text" | where), Locale starter (`key`: "text" — assemble these into your en.json), RTL and formatting notes, Open questions. Empty sections contain the single bullet - None.

curl -s "$BASE/run" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: audit-$(cksum src/App.jsx | cut -d' ' -f1)-$RUN_NONCE" \
  -d @input.json
# if data.status is "queued": poll
curl -s "$BASE/jobs/JOB_ID" -H "Authorization: Bearer $SKILLSAFE_TOKEN"
import hashlib, time
key = "audit-" + hashlib.sha256(
    json.dumps(input_obj, sort_keys=True).encode()).hexdigest()[:16] + "-$RUN_NONCE"

req = urllib.request.Request(BASE + "/run", method="POST",
    data=json.dumps(input_obj).encode(),
    headers={"Authorization": "Bearer " + TOKEN,
             "Content-Type": "application/json",
             "Idempotency-Key": key})
with urllib.request.urlopen(req) as r:
    job = json.load(r)["data"]
while job.get("status") not in ("succeeded", "failed", None):
    time.sleep(1)
    job = api("GET", "/jobs/" + job["job_id"])
report = job["output"]["output"]   # the tagged markdown
print(job["charged_credits"], "credits;", report.splitlines()[0])
const key = "audit-" + contentHash(input) + "-$RUN_NONCE"; // any stable hash
let job = await fetch(BASE + "/run", {
  method: "POST",
  headers: { Authorization: "Bearer " + TOKEN,
             "Content-Type": "application/json",
             "Idempotency-Key": key },
  body: JSON.stringify(input),
}).then((r) => r.json()).then((o) => o.data);
while (job.status === "queued" || job.status === "running") {
  await new Promise((r) => setTimeout(r, 1000));
  job = await api("GET", "/jobs/" + job.job_id);
}
const report = job.output.output;  // VERDICT: ... markdown
// add the header to the helper for this call:
req.Header.Set("Idempotency-Key", "audit-"+hash(input)+"-$RUN_NONCE")
job, _ := api("POST", "/run", input)
for job["status"] == "queued" || job["status"] == "running" {
    time.Sleep(time.Second)
    job, _ = api("GET", "/jobs/"+job["job_id"].(string), nil)
}
report := job["output"].(map[string]any)["output"].(string)
// send with .header("Idempotency-Key", key), then poll:
String job = SkillSafe.api("POST", "/run", input);
// while status is queued/running: SkillSafe.api("GET", "/jobs/" + id, null)
req["Idempotency-Key"] = "audit-#{Digest::SHA256.hexdigest(input.to_json)[0,16]}-$RUN_NONCE"
job = api("POST", "/run", input)
until %w[succeeded failed].include?(job["status"].to_s)
  sleep 1
  job = api("GET", "/jobs/#{job["job_id"]}")
end
report = job.dig("output", "output")
$key = "audit-" . substr(hash("sha256", json_encode($input)), 0, 16) . "-$RUN_NONCE";
// add "Idempotency-Key: $key" to CURLOPT_HTTPHEADER for this call
$job = api("POST", "/run", $input);
while (in_array($job["status"] ?? "", ["queued", "running"])) {
    sleep(1);
    $job = api("GET", "/jobs/" . $job["job_id"]);
}
$report = $job["output"]["output"];
req.Headers.Add("Idempotency-Key", $"audit-{hash}-$RUN_NONCE");
var job = await SkillSafe.Api(HttpMethod.Post, "/run", input);
// poll /jobs/{id} while status is queued/running
var report = job.GetProperty("output").GetProperty("output").GetString();

Parse defensively: strip an outer code fence if one sneaks in, and treat a reply without the VERDICT: line as malformed — re-run once with a retry_note restating the shape. Give that reformat request a different Idempotency-Key (the app appends -reformat to the same content hash and nonce). Reusing the first key here is a trap: the platform would treat it as a duplicate and hand back the very malformed reply the reformat was sent to replace. The reformat is a second billed run, and the app tells the user so before it starts.

Step 5 — stream it live

POST /run-stream

Same input and billing as /run, but the response is Server-Sent Events: event: job (accepted), repeated event: delta ({"text": "..."} chunks), then a terminal event: done whose payload is authoritative (output, charged_credits, truncated) — deltas can drop the tail, so always prefer done.output.output. event: error reports a failed job. Idempotency-Key works here too.

curl -N -s "$BASE/run-stream" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: audit-abc123-$RUN_NONCE" \
  -d @input.json
req = urllib.request.Request(BASE + "/run-stream", method="POST",
    data=json.dumps(input_obj).encode(),
    headers={"Authorization": "Bearer " + TOKEN,
             "Content-Type": "application/json"})
with urllib.request.urlopen(req) as r:
    for raw in r:
        line = raw.decode().strip()
        if line.startswith("data:"):
            evt = json.loads(line[5:])
            if "text" in evt: print(evt["text"], end="", flush=True)
const res = await fetch(BASE + "/run-stream", {
  method: "POST",
  headers: { Authorization: "Bearer " + TOKEN,
             "Content-Type": "application/json" },
  body: JSON.stringify(input),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "";
for (;;) {
  const { done, value } = await reader.read();
  if (done) break;
  buf += dec.decode(value, { stream: true });
  // split on \n\n, parse "event:" and "data:" lines per SSE
}
req, _ := http.NewRequest("POST", base+"/run-stream", &buf)
req.Header.Set("Authorization", "Bearer "+os.Getenv("SKILLSAFE_TOKEN"))
req.Header.Set("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() {
    line := sc.Text()
    if strings.HasPrefix(line, "data:") { fmt.Println(line[5:]) }
}
HttpRequest req = HttpRequest.newBuilder(URI.create(SkillSafe.BASE + "/run-stream"))
    .header("Authorization", "Bearer " + SkillSafe.TOKEN)
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(inputJson)).build();
SkillSafe.HTTP.send(req, HttpResponse.BodyHandlers.ofLines())
    .body().filter(l -> l.startsWith("data:"))
    .forEach(l -> System.out.println(l.substring(5)));
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
  req = Net::HTTP::Post.new(BASE + "/run-stream")
  req["Authorization"] = "Bearer #{TOKEN}"
  req["Content-Type"] = "application/json"
  req.body = input.to_json
  http.request(req) do |res|
    res.read_body { |chunk| print chunk.scan(/data: (.*)/).join("\n") }
  end
end
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => ["Authorization: Bearer $TOKEN",
                           "Content-Type: application/json"],
    CURLOPT_POSTFIELDS => json_encode($input),
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) {
        echo $chunk; return strlen($chunk); // parse SSE lines as they arrive
    },
]);
curl_exec($ch);
var req = new HttpRequestMessage(HttpMethod.Post, SkillSafe.Base + "/run-stream")
    { Content = JsonContent.Create(input) };
req.Headers.Add("Authorization", "Bearer " + token);
var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await res.Content.ReadAsStreamAsync());
while (await sr.ReadLineAsync() is { } line)
    if (line.StartsWith("data:")) Console.WriteLine(line[5..]);

The report's Locale starter bullets parse as `key`: "value" lines — fold them into a flat JSON object and you have the same en.json the app's download button produces.