Driving OCR Lab from your own code
Recognition in this app is local and free: the PP-OCRv6 models ship with the page, run in the browser, and no image is ever uploaded. There is no API for that part, and there does not need to be — the weights are the publisher's unmodified Apache-2.0 files, so you can run exactly the same ONNX models yourself.
What this API exposes is the second half: the metered pass that takes recognised lines and returns a repaired, reordered, optionally structured document. If you already have OCR output from anywhere, you can send it here.
Base URL and envelope
Everything lives under https://api.skillsafe.ai/v1/app-api. Every response is one of two shapes:
{ "data": { ... } } // success
{ "error": { "code": "VALIDATION_ERROR", "message": "...", "details": { ... } } }
Check for error first; the HTTP status mirrors it.
Error codes
| Code | HTTP | What to do |
|---|---|---|
UNAUTHORIZED | 401 | Missing or expired token. Mint a new one on the token page. |
PAYMENT_REQUIRED | 402 | The balance is below min_credits. Nothing ran and nothing was charged. |
VALIDATION_ERROR | 400 | The input did not match the shape below — usually ocr.lines missing or empty. |
NOT_FOUND | 404 | Unknown job id, record id, or collection. |
RATE_LIMITED | 429 | Too many requests. /similar is the tightest at 30/min per IP. |
INTERNAL | 500 | Platform-side failure. A failed run refunds its whole hold. |
1. Get a token
Every call carries Authorization: Bearer <token>. The app already
holds one, and the token page shows it, copies it, and prints a
shell export — you never need to open a developer console. Two kinds exist:
- Guest tokens are minted automatically and can read and estimate, but a metered run needs a signed-in user (or an app with sponsorship on, which this one does not have).
- Personal tokens come from signing in and spend your credits.
Recognition itself needs no token and no account. It fetches the bundled PP-OCRv6 weights from this origin once and then runs entirely in the tab — no image is ever uploaded, and nothing on the recognition path is billed. This API covers the repair pass only.
2. Check the session and balance
GET /me tells you who the token belongs to and what it can afford. Do this
before a run: comparing credits against the hold from step 3 is what stops a
402 from happening after you have already built the payload.
curl -sS -X GET 'https://api.skillsafe.ai/v1/app-api/me' \
-H 'Authorization: Bearer YOUR_TOKEN'
import json, urllib.request
TOKEN = "YOUR_TOKEN" # from https://ocr-lab.skillsafe.ai/tokens.html
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/me",
headers={"Authorization": "Bearer " + TOKEN},
method="GET")
with urllib.request.urlopen(req) as res:
body = json.load(res)
print(body["data"])
const TOKEN = "YOUR_TOKEN";
const res = await fetch("https://api.skillsafe.ai/v1/app-api/me", {
method: "GET",
headers: {
Authorization: `Bearer ${TOKEN}`,
},
});
const { data, error } = await res.json();
if (error) throw new Error(`${error.code}: ${error.message}`);
console.log(data);
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
func main() {
token := "YOUR_TOKEN"
req, _ := http.NewRequest("GET", "https://api.skillsafe.ai/v1/app-api/me", nil)
_ = bytes.MinRead
_ = json.Valid
req.Header.Set("Authorization", "Bearer "+token)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(string(out))
}
import java.net.URI;
import java.net.http.*;
var token = "YOUR_TOKEN";
var builder = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/me"))
.header("Authorization", "Bearer " + token);
var req = builder.method("GET", HttpRequest.BodyPublishers.noBody()).build();
var res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.body());
require "json"
require "net/http"
token = "YOUR_TOKEN"
uri = URI("https://api.skillsafe.ai/v1/app-api/me")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{token}"
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body)["data"]
<?php
$token = "YOUR_TOKEN";
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/me");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => "GET",
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $token",
],
]);
$body = json_decode(curl_exec($ch), true);
print_r($body["data"]);
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
var token = "YOUR_TOKEN";
var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var res = await http.GetAsync("https://api.skillsafe.ai/v1/app-api/me");
Console.WriteLine(await res.Content.ReadAsStringAsync());
3. Price the run
POST /estimate is free, creates no job, and charges nothing. It returns the
model actually bound to the app, the markup, and two numbers that matter:
hold_credits (what gets reserved, priced against the full output cap) and
min_credits (the floor below which the run is refused). The real charge is
almost always well under the hold.
{
"model": "gpt-5.6-terra",
"model_alias": "gpt-terra",
"markup_bps": 1000,
"hold_credits": 1703,
"min_credits": 215,
"sponsor_enabled": false
}
curl -sS -X POST 'https://api.skillsafe.ai/v1/app-api/estimate' \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"task": "clean",
"target": "markdown",
"language_hint": "mixed",
"translate_to": "",
"instructions": "keep the table columns",
"image": {
"width": 720,
"height": 300
},
"ocr": {
"engine": "PP-OCRv6_tiny · onnxruntime-web wasm",
"line_count": 2,
"mean_confidence": 0.9962,
"low_confidence_threshold": 0.75,
"low_confidence_lines": [],
"clip_note": "",
"lines": [
{
"i": 1,
"text": "浏览器端文字识别",
"conf": 0.999,
"box": [
38,
29,
311,
66
]
},
{
"i": 2,
"text": "PP-OCRv6 tiny 6MB",
"conf": 0.994,
"box": [
38,
100,
314,
135
]
}
]
}
}'
import json, urllib.request
TOKEN = "YOUR_TOKEN" # from https://ocr-lab.skillsafe.ai/tokens.html
payload = {
"task": "clean",
"target": "markdown",
"language_hint": "mixed",
"translate_to": "",
"instructions": "keep the table columns",
"image": {
"width": 720,
"height": 300
},
"ocr": {
"engine": "PP-OCRv6_tiny · onnxruntime-web wasm",
"line_count": 2,
"mean_confidence": 0.9962,
"low_confidence_threshold": 0.75,
"low_confidence_lines": [],
"clip_note": "",
"lines": [
{
"i": 1,
"text": "浏览器端文字识别",
"conf": 0.999,
"box": [
38,
29,
311,
66
]
},
{
"i": 2,
"text": "PP-OCRv6 tiny 6MB",
"conf": 0.994,
"box": [
38,
100,
314,
135
]
}
]
}
}
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/estimate",
data=json.dumps(payload).encode("utf-8"),
headers={"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json"},
method="POST")
with urllib.request.urlopen(req) as res:
body = json.load(res)
print(body["data"])
const TOKEN = "YOUR_TOKEN";
const res = await fetch("https://api.skillsafe.ai/v1/app-api/estimate", {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"task": "clean",
"target": "markdown",
"language_hint": "mixed",
"translate_to": "",
"instructions": "keep the table columns",
"image": {
"width": 720,
"height": 300
},
"ocr": {
"engine": "PP-OCRv6_tiny · onnxruntime-web wasm",
"line_count": 2,
"mean_confidence": 0.9962,
"low_confidence_threshold": 0.75,
"low_confidence_lines": [],
"clip_note": "",
"lines": [
{
"i": 1,
"text": "浏览器端文字识别",
"conf": 0.999,
"box": [
38,
29,
311,
66
]
},
{
"i": 2,
"text": "PP-OCRv6 tiny 6MB",
"conf": 0.994,
"box": [
38,
100,
314,
135
]
}
]
}
}),
});
const { data, error } = await res.json();
if (error) throw new Error(`${error.code}: ${error.message}`);
console.log(data);
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
func main() {
token := "YOUR_TOKEN"
payload := []byte(`{
"task": "clean",
"target": "markdown",
"language_hint": "mixed",
"translate_to": "",
"instructions": "keep the table columns",
"image": {
"width": 720,
"height": 300
},
"ocr": {
"engine": "PP-OCRv6_tiny · onnxruntime-web wasm",
"line_count": 2,
"mean_confidence": 0.9962,
"low_confidence_threshold": 0.75,
"low_confidence_lines": [],
"clip_note": "",
"lines": [
{
"i": 1,
"text": "浏览器端文字识别",
"conf": 0.999,
"box": [
38,
29,
311,
66
]
},
{
"i": 2,
"text": "PP-OCRv6 tiny 6MB",
"conf": 0.994,
"box": [
38,
100,
314,
135
]
}
]
}
}`)
req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/estimate", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(string(out))
}
import java.net.URI;
import java.net.http.*;
var token = "YOUR_TOKEN";
var builder = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/estimate"))
.header("Authorization", "Bearer " + token);
var payload = """
{
"task": "clean",
"target": "markdown",
"language_hint": "mixed",
"translate_to": "",
"instructions": "keep the table columns",
"image": {
"width": 720,
"height": 300
},
"ocr": {
"engine": "PP-OCRv6_tiny · onnxruntime-web wasm",
"line_count": 2,
"mean_confidence": 0.9962,
"low_confidence_threshold": 0.75,
"low_confidence_lines": [],
"clip_note": "",
"lines": [
{
"i": 1,
"text": "浏览器端文字识别",
"conf": 0.999,
"box": [
38,
29,
311,
66
]
},
{
"i": 2,
"text": "PP-OCRv6 tiny 6MB",
"conf": 0.994,
"box": [
38,
100,
314,
135
]
}
]
}
}
""";
var req = builder.header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString(payload)).build();
var res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.body());
require "json"
require "net/http"
token = "YOUR_TOKEN"
uri = URI("https://api.skillsafe.ai/v1/app-api/estimate")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{token}"
req["Content-Type"] = "application/json"
req.body = JSON.generate({
"task": "clean",
"target": "markdown",
"language_hint": "mixed",
"translate_to": "",
"instructions": "keep the table columns",
"image": {
"width": 720,
"height": 300
},
"ocr": {
"engine": "PP-OCRv6_tiny · onnxruntime-web wasm",
"line_count": 2,
"mean_confidence": 0.9962,
"low_confidence_threshold": 0.75,
"low_confidence_lines": [],
"clip_note": "",
"lines": [
{
"i": 1,
"text": "浏览器端文字识别",
"conf": 0.999,
"box": [
38,
29,
311,
66
]
},
{
"i": 2,
"text": "PP-OCRv6 tiny 6MB",
"conf": 0.994,
"box": [
38,
100,
314,
135
]
}
]
}
})
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body)["data"]
<?php
$token = "YOUR_TOKEN";
$payload = <<<'JSON'
{
"task": "clean",
"target": "markdown",
"language_hint": "mixed",
"translate_to": "",
"instructions": "keep the table columns",
"image": {
"width": 720,
"height": 300
},
"ocr": {
"engine": "PP-OCRv6_tiny · onnxruntime-web wasm",
"line_count": 2,
"mean_confidence": 0.9962,
"low_confidence_threshold": 0.75,
"low_confidence_lines": [],
"clip_note": "",
"lines": [
{
"i": 1,
"text": "浏览器端文字识别",
"conf": 0.999,
"box": [
38,
29,
311,
66
]
},
{
"i": 2,
"text": "PP-OCRv6 tiny 6MB",
"conf": 0.994,
"box": [
38,
100,
314,
135
]
}
]
}
}
JSON;
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/estimate");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $token",
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => $payload,
]);
$body = json_decode(curl_exec($ch), true);
print_r($body["data"]);
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
var token = "YOUR_TOKEN";
var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var payload = @"{
""task"": ""clean"",
""target"": ""markdown"",
""language_hint"": ""mixed"",
""translate_to"": """",
""instructions"": ""keep the table columns"",
""image"": {
""width"": 720,
""height"": 300
},
""ocr"": {
""engine"": ""PP-OCRv6_tiny · onnxruntime-web wasm"",
""line_count"": 2,
""mean_confidence"": 0.9962,
""low_confidence_threshold"": 0.75,
""low_confidence_lines"": [],
""clip_note"": """",
""lines"": [
{
""i"": 1,
""text"": ""浏览器端文字识别"",
""conf"": 0.999,
""box"": [
38,
29,
311,
66
]
},
{
""i"": 2,
""text"": ""PP-OCRv6 tiny 6MB"",
""conf"": 0.994,
""box"": [
38,
100,
314,
135
]
}
]
}
}";
var content = new StringContent(payload, Encoding.UTF8, "application/json");
var res = await http.PostAsync("https://api.skillsafe.ai/v1/app-api/estimate", content);
Console.WriteLine(await res.Content.ReadAsStringAsync());
4. Run it
The input is the recogniser's output plus what you want done with it. Every field:
| Field | Type | Meaning |
|---|---|---|
task | string | "clean" — the only task this app sends. |
target | string | text, markdown, fields or translate. Decides the shape of TEXT and whether FIELDS is populated. |
language_hint | string | auto, zh, en or mixed. |
translate_to | string | Target language. Only read when target is translate. |
instructions | string | Free-text note, truncated to 300 characters. |
image | object | {width, height} in pixels — the frame the boxes are in. |
ocr.engine | string | Which recogniser produced the lines. |
ocr.line_count | number | How many lines were recognised in total, before any clipping. |
ocr.mean_confidence | number | 0–1, averaged over lines. |
ocr.low_confidence_threshold | number | Below this a line is treated as suspect. The app sends 0.75. |
ocr.low_confidence_lines | number[] | 1-based indices of the suspect lines. |
ocr.clip_note | string | Non-empty when the page was too long for the budget and middle lines were elided. |
ocr.lines[] | array | {i, text, conf, box}. box is [x0,y0,x1,y1], or null for an elision marker. |
$model | string | Optional per-run model alias override. Omit to use the app's own. |
Pass an Idempotency-Key derived from the content. A retry with the same key
returns the original job instead of billing a second time — this is what makes a network
blip safe.
# 1. submit — Idempotency-Key makes a retry safe
JOB=$(curl -sS -X POST 'https://api.skillsafe.ai/v1/app-api/run' \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: clean-9f2ab41c:0' \
-d @input.json | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["job_id"])')
# 2. poll until status is succeeded or failed
while :; do
BODY=$(curl -sS "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" -H 'Authorization: Bearer YOUR_TOKEN')
STATUS=$(printf '%s' "$BODY" | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["status"])')
[ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
sleep 2
done
printf '%s' "$BODY" | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["output"]["output"])'
import json, time, urllib.request
TOKEN = "YOUR_TOKEN"
API = "https://api.skillsafe.ai/v1/app-api"
def call(method, path, payload=None, extra=None):
headers = {"Authorization": "Bearer " + TOKEN}
data = None
if payload is not None:
headers["Content-Type"] = "application/json"
data = json.dumps(payload).encode("utf-8")
headers.update(extra or {})
req = urllib.request.Request(API + path, data=data, headers=headers, method=method)
with urllib.request.urlopen(req) as res:
return json.load(res)["data"]
payload = json.load(open("input.json"))
# A content hash keeps a retry from becoming a second charge.
key = "clean-9f2ab41c:0"
job = call("POST", "/run", payload, {"Idempotency-Key": key})
while True:
state = call("GET", "/jobs/" + job["job_id"])
if state["status"] in ("succeeded", "failed"):
break
time.sleep(2)
if state["status"] == "failed":
raise SystemExit(state.get("error"))
print(state["output"]["output"])
const TOKEN = "YOUR_TOKEN";
const API = "https://api.skillsafe.ai/v1/app-api";
async function call(method, path, body, extra = {}) {
const res = await fetch(API + path, {
method,
headers: {
Authorization: `Bearer ${TOKEN}`,
...(body ? { "Content-Type": "application/json" } : {}),
...extra,
},
body: body ? JSON.stringify(body) : undefined,
});
const { data, error } = await res.json();
if (error) throw new Error(`${error.code}: ${error.message}`);
return data;
}
const job = await call("POST", "/run", input, { "Idempotency-Key": "clean-9f2ab41c:0" });
let state;
do {
await new Promise((r) => setTimeout(r, 2000));
state = await call("GET", `/jobs/${job.job_id}`);
} while (state.status !== "succeeded" && state.status !== "failed");
if (state.status === "failed") throw new Error(state.error);
console.log(state.output.output);
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
)
const api = "https://api.skillsafe.ai/v1/app-api"
func call(method, path string, body []byte, key string) map[string]any {
req, _ := http.NewRequest(method, api+path, bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer YOUR_TOKEN")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if key != "" {
req.Header.Set("Idempotency-Key", key)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var env struct {
Data map[string]any `json:"data"`
}
json.NewDecoder(res.Body).Decode(&env)
return env.Data
}
func main() {
job := call("POST", "/run", input, "clean-9f2ab41c:0")
for {
state := call("GET", "/jobs/"+job["job_id"].(string), nil, "")
if s := state["status"].(string); s == "succeeded" || s == "failed" {
out := state["output"].(map[string]any)
fmt.Println(out["output"])
return
}
time.Sleep(2 * time.Second)
}
}
import java.net.URI;
import java.net.http.*;
var api = "https://api.skillsafe.ai/v1/app-api";
var http = HttpClient.newHttpClient();
HttpRequest.Builder auth(String path) {
return HttpRequest.newBuilder(URI.create(api + path))
.header("Authorization", "Bearer YOUR_TOKEN");
}
var submit = auth("/run")
.header("Content-Type", "application/json")
.header("Idempotency-Key", "clean-9f2ab41c:0")
.POST(HttpRequest.BodyPublishers.ofString(input))
.build();
var jobBody = http.send(submit, HttpResponse.BodyHandlers.ofString()).body();
var jobId = jobBody.replaceAll(".*\"job_id\"\\s*:\\s*\"([^\"]+)\".*", "$1");
String state;
do {
Thread.sleep(2000);
state = http.send(auth("/jobs/" + jobId).GET().build(),
HttpResponse.BodyHandlers.ofString()).body();
} while (!state.contains("\"succeeded\"") && !state.contains("\"failed\""));
System.out.println(state);
require "json"
require "net/http"
API = URI("https://api.skillsafe.ai/v1/app-api")
TOKEN = "YOUR_TOKEN"
def call(method, path, payload = nil, extra = {})
uri = URI(API.to_s + path)
klass = method == "POST" ? Net::HTTP::Post : Net::HTTP::Get
req = klass.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
extra.each { |k, v| req[k] = v }
if payload
req["Content-Type"] = "application/json"
req.body = JSON.generate(payload)
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
JSON.parse(res.body)["data"]
end
job = call("POST", "/run", input, { "Idempotency-Key" => "clean-9f2ab41c:0" })
loop do
state = call("GET", "/jobs/#{job["job_id"]}")
if %w[succeeded failed].include?(state["status"])
puts state.dig("output", "output")
break
end
sleep 2
end
<?php
$api = "https://api.skillsafe.ai/v1/app-api";
$token = "YOUR_TOKEN";
function call(string $method, string $path, ?array $payload = null, array $extra = []): array {
global $api, $token;
$headers = array_merge(["Authorization: Bearer $token"], $extra);
if ($payload !== null) { $headers[] = "Content-Type: application/json"; }
$ch = curl_init($api . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload === null ? null : json_encode($payload),
]);
return json_decode(curl_exec($ch), true)["data"];
}
$job = call("POST", "/run", $input, ["Idempotency-Key: clean-9f2ab41c:0"]);
do {
sleep(2);
$state = call("GET", "/jobs/" . $job["job_id"]);
} while (!in_array($state["status"], ["succeeded", "failed"], true));
echo $state["output"]["output"];
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
var api = "https://api.skillsafe.ai/v1/app-api";
var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_TOKEN");
var body = new StringContent(input, Encoding.UTF8, "application/json");
body.Headers.Add("Idempotency-Key", "clean-9f2ab41c:0");
var submitted = await http.PostAsync($"{api}/run", body);
var job = JsonDocument.Parse(await submitted.Content.ReadAsStringAsync())
.RootElement.GetProperty("data").GetProperty("job_id").GetString();
JsonElement state;
do {
await Task.Delay(2000);
var polled = await http.GetStringAsync($"{api}/jobs/{job}");
state = JsonDocument.Parse(polled).RootElement.GetProperty("data");
} while (state.GetProperty("status").GetString() is not ("succeeded" or "failed"));
Console.WriteLine(state.GetProperty("output").GetProperty("output").GetString());
5. Stream it instead
A page of text takes long enough that you usually want it as it arrives.
POST /run-stream is the same input and the same billing over server-sent
events: delta carries text, job fires once the job row exists,
and done carries charged_credits and truncated.
truncated: true means the balance sat between min_credits and
hold_credits, so the run executed with a reduced output cap. Treat the answer
as incomplete rather than final.
curl -N -X POST 'https://api.skillsafe.ai/v1/app-api/run-stream' \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: clean-9f2ab41c:0' \
-d @input.json
# Server-sent events. `delta` carries output as it generates; `job` fires once the
# job row exists; `done` carries the settled charge.
# event: delta
# data: {"text":"VERDICT>>> clean\n"}
# event: done
# data: {"job_id":"job_...","charged_credits":214,"truncated":false}
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/run-stream",
data=json.dumps(payload).encode("utf-8"),
headers={"Authorization": "Bearer YOUR_TOKEN",
"Content-Type": "application/json",
"Idempotency-Key": "clean-9f2ab41c:0"},
method="POST")
raw = []
event = None
with urllib.request.urlopen(req) as res:
for line in res:
line = line.decode("utf-8").rstrip("\n")
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
body = json.loads(line[5:].strip())
if event == "delta":
raw.append(body["text"])
elif event == "done":
print("charged", body.get("charged_credits"))
print("".join(raw))
// The app itself uses the SDK, which wraps exactly this:
// ss.runStream(input, { onDelta, onJob, idempotencyKey })
const res = await fetch("https://api.skillsafe.ai/v1/app-api/run-stream", {
method: "POST",
headers: {
Authorization: "Bearer YOUR_TOKEN",
"Content-Type": "application/json",
"Idempotency-Key": "clean-9f2ab41c:0",
},
body: JSON.stringify(input),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "", raw = "", event = null;
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) {
const body = JSON.parse(line.slice(5).trim());
if (event === "delta") raw += body.text;
if (event === "done") console.log("charged", body.charged_credits);
}
}
}
console.log(raw);
package main
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
)
func main() {
req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/run-stream", bytes.NewReader(input))
req.Header.Set("Authorization", "Bearer YOUR_TOKEN")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "clean-9f2ab41c:0")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var raw strings.Builder
event := ""
scan := bufio.NewScanner(res.Body)
scan.Buffer(make([]byte, 1024*1024), 1024*1024)
for scan.Scan() {
line := scan.Text()
switch {
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:"):
var body map[string]any
json.Unmarshal([]byte(strings.TrimSpace(line[5:])), &body)
if event == "delta" {
raw.WriteString(body["text"].(string))
}
}
}
fmt.Println(raw.String())
}
import java.net.URI;
import java.net.http.*;
var req = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/run-stream"))
.header("Authorization", "Bearer YOUR_TOKEN")
.header("Content-Type", "application/json")
.header("Idempotency-Key", "clean-9f2ab41c:0")
.POST(HttpRequest.BodyPublishers.ofString(input))
.build();
var raw = new StringBuilder();
var event = new String[]{""};
HttpClient.newHttpClient()
.send(req, HttpResponse.BodyHandlers.ofLines())
.body()
.forEach(line -> {
if (line.startsWith("event:")) event[0] = line.substring(6).trim();
else if (line.startsWith("data:") && event[0].equals("delta")) {
var d = line.substring(5).trim();
// the delta payload is {"text":"..."}
raw.append(d);
}
});
System.out.println(raw);
require "json"
require "net/http"
uri = URI("https://api.skillsafe.ai/v1/app-api/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer YOUR_TOKEN"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "clean-9f2ab41c:0"
req.body = JSON.generate(input)
raw = ""
event = nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.start_with?("event:")
event = line[6..].strip
elsif line.start_with?("data:")
body = JSON.parse(line[5..].strip)
raw << body["text"] if event == "delta"
puts "charged #{body["charged_credits"]}" if event == "done"
end
end
end
end
end
puts raw
<?php
$raw = "";
$event = null;
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer YOUR_TOKEN",
"Content-Type: application/json",
"Idempotency-Key: clean-9f2ab41c:0",
],
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$event) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "event:")) {
$event = trim(substr($line, 6));
} elseif (str_starts_with($line, "data:")) {
$body = json_decode(trim(substr($line, 5)), true);
if ($event === "delta") { $raw .= $body["text"]; }
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
echo $raw;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
var http = new HttpClient();
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream") {
Content = new StringContent(input, Encoding.UTF8, "application/json")
};
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_TOKEN");
req.Headers.Add("Idempotency-Key", "clean-9f2ab41c:0");
using var res = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var raw = new StringBuilder();
string? evt = null, line;
while ((line = await reader.ReadLineAsync()) is not null) {
if (line.StartsWith("event:")) evt = line[6..].Trim();
else if (line.StartsWith("data:")) {
var body = JsonDocument.Parse(line[5..].Trim()).RootElement;
if (evt == "delta") raw.Append(body.GetProperty("text").GetString());
}
}
Console.WriteLine(raw);
6. Parse the output contract
The reply is marker-delimited, not JSON — a transcription full of quotes and newlines survives markers far more reliably than it survives a model's attempt at valid JSON.
VERDICT>>> noisy
SUMMARY>>> A Chinese-language product screenshot with one mis-read currency amount.
TEXT>>>
## 浏览器端文字识别
PP-OCRv6 tiny 6MB
<<<TEXT
FIELDS>>>
{ }
<<<FIELDS
FLAGS>>>
- L4 (conf 0.52) "1O0.5O" -> "100.50": zero/letter-O confusion in an amount
<<<FLAGS
NOTES>>>
None.
<<<NOTES
VERDICTisclean,noisyorunusable.TEXTis required and never empty. Everything else may be absent.FIELDSis a JSON object,{ }when there is nothing structured.FLAGSis one-bullet per correction, orNone.- A missing closing marker means the stream was cut — keep what parsed rather than discarding the answer.
The app also cross-checks TEXT against the recogniser's own characters and
reports anything the model introduced. If you drive this API yourself, do the same: it is
the cheapest hallucination check available, and it costs nothing.
7. Read past scans
Scans the app saved live in the scans collection, scoped to the calling
subject. query filters and sorts; similar is vector search over
the embedded title and preview fields. Note that every
where entry must be an operator object — a bare value is rejected — and that
the sort key is sort, not order_by.
curl -sS -X POST 'https://api.skillsafe.ai/v1/app-api/collections/scans/query' \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"where": {
"mean_conf": {
"lt": 0.9
}
},
"sort": {
"field": "ran_at",
"dir": "desc"
},
"limit": 10
}'
import json, urllib.request
TOKEN = "YOUR_TOKEN" # from https://ocr-lab.skillsafe.ai/tokens.html
payload = {
"where": {
"mean_conf": {
"lt": 0.9
}
},
"sort": {
"field": "ran_at",
"dir": "desc"
},
"limit": 10
}
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/collections/scans/query",
data=json.dumps(payload).encode("utf-8"),
headers={"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json"},
method="POST")
with urllib.request.urlopen(req) as res:
body = json.load(res)
print(body["data"])
const TOKEN = "YOUR_TOKEN";
const res = await fetch("https://api.skillsafe.ai/v1/app-api/collections/scans/query", {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"where": {
"mean_conf": {
"lt": 0.9
}
},
"sort": {
"field": "ran_at",
"dir": "desc"
},
"limit": 10
}),
});
const { data, error } = await res.json();
if (error) throw new Error(`${error.code}: ${error.message}`);
console.log(data);
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
func main() {
token := "YOUR_TOKEN"
payload := []byte(`{
"where": {
"mean_conf": {
"lt": 0.9
}
},
"sort": {
"field": "ran_at",
"dir": "desc"
},
"limit": 10
}`)
req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/collections/scans/query", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(string(out))
}
import java.net.URI;
import java.net.http.*;
var token = "YOUR_TOKEN";
var builder = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/collections/scans/query"))
.header("Authorization", "Bearer " + token);
var payload = """
{
"where": {
"mean_conf": {
"lt": 0.9
}
},
"sort": {
"field": "ran_at",
"dir": "desc"
},
"limit": 10
}
""";
var req = builder.header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString(payload)).build();
var res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.body());
require "json"
require "net/http"
token = "YOUR_TOKEN"
uri = URI("https://api.skillsafe.ai/v1/app-api/collections/scans/query")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{token}"
req["Content-Type"] = "application/json"
req.body = JSON.generate({
"where": {
"mean_conf": {
"lt": 0.9
}
},
"sort": {
"field": "ran_at",
"dir": "desc"
},
"limit": 10
})
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body)["data"]
<?php
$token = "YOUR_TOKEN";
$payload = <<<'JSON'
{
"where": {
"mean_conf": {
"lt": 0.9
}
},
"sort": {
"field": "ran_at",
"dir": "desc"
},
"limit": 10
}
JSON;
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/collections/scans/query");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $token",
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => $payload,
]);
$body = json_decode(curl_exec($ch), true);
print_r($body["data"]);
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
var token = "YOUR_TOKEN";
var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var payload = @"{
""where"": {
""mean_conf"": {
""lt"": 0.9
}
},
""sort"": {
""field"": ""ran_at"",
""dir"": ""desc""
},
""limit"": 10
}";
var content = new StringContent(payload, Encoding.UTF8, "application/json");
var res = await http.PostAsync("https://api.skillsafe.ai/v1/app-api/collections/scans/query", content);
Console.WriteLine(await res.Content.ReadAsStringAsync());
Rates and limits worth knowing
- The app is bound to the
gpt-terraalias at a 10% publisher markup./estimateis authoritative about which concrete model that resolves to today. - Data endpoints allow 120 requests a minute;
/similarallows 30 a minute per IP and costs roughly ten times a plainquery. Usewherewhenever an exact filter would do. - A record is capped at 64 KB, so the app trims stored text and drops box geometry before it stores a very long page.
- A failed run refunds its entire hold. A truncated run does not — it did real work under a reduced cap.