Drive KYC Desk from your own code
Every lane of the app is one HTTP call. The base URL is
https://api.skillsafe.ai/v1/app-api, the auth is a bearer token, and every response is the same
envelope: {"data": ...} on success, {"error": {"code", "message", "details"}}
on failure. Pick a language once — the choice sticks across every step on this page.
The client-side scan that runs in the browser does not run here: from a script,
prescan is null and the model works from the packet alone. If you want
the scan's arithmetic in your pipeline, use the page.
The error codes
| HTTP | code | What it means |
|---|---|---|
400 | VALIDATION_ERROR | The body is not the shape the app expects. Most often the input object was wrapped in an input key — it must be the body itself. |
401 | UNAUTHORIZED | No token, or a token for a different app. Mint one at tokens.html. |
402 | INSUFFICIENT_CREDITS | The balance is below min_credits. Compare /me against /estimate before calling /run. |
404 | NOT_FOUND | Wrong path, or a job id from another app. |
409 | CONFLICT | An Idempotency-Key was reused with a different body. Change the key or send the original body. |
429 | RATE_LIMITED | Back off and retry. Vector search is 30/min per IP; the other data endpoints share 120/min. |
503 | UNAVAILABLE | A capability that is not enabled on this deployment. Server functions and the sandbox both 503 by design. |
1. A tiny client helper
Everything below goes through one small function so the interesting part of each step is the body, not the plumbing. The base URL is https://api.skillsafe.ai/v1/app-api and every response is the same {data} / {error} envelope.
# Every call in this guide uses these two values.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN" # from https://kyc-desk.skillsafe.ai/tokens.html
# A tiny helper so the rest of the guide stays short.
ss() { # ss <method> <path> [json-body]
if [ -n "$3" ]; then
curl -sS -X "$1" "$BASE$2" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$3"
else
curl -sS -X "$1" "$BASE$2" -H "Authorization: Bearer $TOKEN"
fi
}
import json, urllib.request, urllib.error
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # or os.environ.get("SKILLSAFE_TOKEN")
def ss(method, path, body=None, token=TOKEN):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data=data, method=method)
req.add_header("Content-Type", "application/json")
if token:
req.add_header("Authorization", "Bearer " + token)
try:
with urllib.request.urlopen(req) as r:
return json.loads(r.read())
except urllib.error.HTTPError as ex:
return json.loads(ex.read())
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from the tokens page
async function ss(method, path, body, token = TOKEN) {
const res = await fetch(BASE + path, {
method,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: body === undefined ? undefined : JSON.stringify(body)
});
return res.json(); // always the {data} / {error} envelope
}
package main
import (
"bytes"
"encoding/json"
"io"
"net/http"
"os"
)
const base = "https://api.skillsafe.ai/v1/app-api"
var token = os.Getenv("SKILLSAFE_TOKEN") // or the literal "YOUR_TOKEN"
func ss(method, path string, body any) (map[string]any, error) {
var r io.Reader
if body != nil {
b, _ := json.Marshal(body)
r = bytes.NewReader(b)
}
req, err := http.NewRequest(method, base+path, r)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var out map[string]any
return out, json.NewDecoder(res.Body).Decode(&out)
}
import java.net.URI;
import java.net.http.*;
class Ss {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN";
static final HttpClient HTTP = HttpClient.newHttpClient();
static String ss(String method, String path, String json) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Content-Type", "application/json");
if (token != null && !token.isEmpty()) {
b.header("Authorization", "Bearer " + token);
}
b.method(method, json == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(json));
return HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString()).body();
}
}
require "json"
require "net/http"
BASE = URI("https://api.skillsafe.ai/v1/app-api")
TOKEN = "YOUR_TOKEN"
def ss(method, path, body = nil, token = TOKEN)
uri = URI(BASE.to_s + path)
klass = { "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post }[method]
req = klass.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{token}" if token
req.body = JSON.dump(body) if body
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
JSON.parse(res.body)
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "YOUR_TOKEN";
function ss($method, $path, $body = null, $token = null) {
$headers = ["Content-Type: application/json"];
if ($token) { $headers[] = "Authorization: Bearer " . $token; }
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$out = curl_exec($ch);
curl_close($ch);
return json_decode($out, true);
}
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
static class Ss {
const string Base = "https://api.skillsafe.ai/v1/app-api";
static string Token = "YOUR_TOKEN";
static readonly HttpClient Http = new HttpClient();
public static async Task<JsonDocument> Call(HttpMethod method, string path, object body = null) {
var req = new HttpRequestMessage(method, Base + path);
if (!string.IsNullOrEmpty(Token)) {
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
}
if (body != null) {
req.Content = new StringContent(JsonSerializer.Serialize(body),
Encoding.UTF8, "application/json");
}
var res = await Http.SendAsync(req);
return JsonDocument.Parse(await res.Content.ReadAsStringAsync());
}
}
2. Get a token
A personal token comes from tokens.html. A guest token is one unauthenticated POST. Note two things that surprise people: the body key is slug, and there is no X-App-Slug header in this API — the slug appears only in this body.
# A guest token needs NO Authorization header. The body key is "slug".
curl -sS -X POST "$BASE/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"kyc-desk"}'
# -> {"data":{"token":"aut_...","is_guest":true,"credits":0}}
# There is NO X-App-Slug header anywhere in this API. The slug appears only
# in this body; a bogus header value changes nothing and still returns 200.
# The body key is "slug" - not "app_slug".
res = ss("POST", "/guest", {"slug": "kyc-desk"}, token=None)
TOKEN = res["data"]["token"]
print(res["data"]["is_guest"], res["data"]["credits"])
// The body key is "slug" - not "app_slug".
const g = await ss("POST", "/guest", { slug: "kyc-desk" }, null);
TOKEN = g.data.token;
console.log(g.data.is_guest, g.data.credits);
// The body key is "slug" - not "app_slug".
g, _ := ss("POST", "/guest", map[string]any{"slug": "kyc-desk"})
d := g["data"].(map[string]any)
token = d["token"].(string)
// The body key is "slug" - not "app_slug".
Ss.token = ""; // no Authorization on /guest
String g = Ss.ss("POST", "/guest", "{\"slug\":\"kyc-desk\"}");
// parse g and assign Ss.token = data.token
# The body key is "slug" - not "app_slug".
g = ss("POST", "/guest", { "slug" => "kyc-desk" }, nil)
token = g["data"]["token"]
<?php
// The body key is "slug" - not "app_slug".
$g = ss("POST", "/guest", ["slug" => "kyc-desk"]);
$TOKEN = $g["data"]["token"];
// The body key is "slug" - not "app_slug".
var g = await Ss.Call(HttpMethod.Post, "/guest", new { slug = "kyc-desk" });
var token = g.RootElement.GetProperty("data").GetProperty("token").GetString();
3. Check the session and the balance
/me tells you whether the token is personal or guest and what it can spend. Do this before pricing anything.
ss GET /me
# -> {"data":{"user_id":"usr_...","is_guest":false,"credits":184203}}
# Compare credits against min_credits from /estimate BEFORE you call /run.
me = ss("GET", "/me")["data"]
print(me["credits"], me["is_guest"])
const me = (await ss("GET", "/me")).data;
console.log(me.credits, me.is_guest);
m, _ := ss("GET", "/me", nil)
me := m["data"].(map[string]any)
String me = Ss.ss("GET", "/me", null);
me = ss("GET", "/me")["data"]
<?php
$me = ss("GET", "/me", null, $TOKEN)["data"];
var me = await Ss.Call(HttpMethod.Get, "/me");
4. Price the run — free
/estimate creates no job and charges nothing. It is also the authoritative check that the app is bound to the model you expect.
# THE BODY IS THE INPUT OBJECT ITSELF. There is no "input" wrapper.
# A wrapped body returns 200 with a plausible hold, and the model then never
# sees `task` - so a real /run bills against a payload the prompt cannot read,
# with no error to catch. The check that works: send a bare {"task"} with no
# packet and confirm the hold DROPS materially. If it does not, you are wrapped.
ss POST /estimate '{"task":"cip","packet":"Account type: LLC brokerage account\nEntity name: Meridian Trading LLC\nJurisdiction of formation: Delaware, United States\nEIN: 47-3928104\nSource of funds: retained operating profit; 2025 tax return attached\nSanctions screening: cleared 2026-08-02, no matches\nPEP: not a politically exposed person\n\nBeneficial owners: Harbor Holdings LLC (60%), Ana Reyes (25%), Reyes Family Trust (15%)\nAna Reyes owns 40% of Harbor Holdings LLC","packet_clipped":0,"notes":"","posture":"standard","masking_on":false,"redaction":[],"prescan":null,"upstream":""}'
# -> {"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
# "markup_bps":1000,"hold_credits":4182,"min_credits":418,
# "sponsor_enabled":false}}
# /estimate is FREE. It creates no job and charges nothing.
# THE BODY IS THE INPUT OBJECT ITSELF - no "input" wrapper.
packet = open("packet.txt").read()
inp = {
"task": "cip", # parse | cip | risk | aml | rfi
"packet": packet,
"packet_clipped": 0,
"notes": "",
"posture": "standard", # or "conservative"
"masking_on": False,
"redaction": [],
"prescan": None, # the browser's scan; None is legal from a script
"upstream": ""
}
est = ss("POST", "/estimate", inp)["data"]
assert est["model_alias"] == "gpt-terra"
print(est["hold_credits"], est["min_credits"])
// THE BODY IS THE INPUT OBJECT ITSELF - no "input" wrapper.
const input = {
task: "cip", // parse | cip | risk | aml | rfi
packet,
packet_clipped: 0,
notes: "",
posture: "standard",
masking_on: false,
redaction: [],
prescan: null,
upstream: ""
};
const est = (await ss("POST", "/estimate", input)).data;
console.log(est.model_alias, est.hold_credits);
// THE BODY IS THE INPUT OBJECT ITSELF - no "input" wrapper.
input := map[string]any{
"task": "cip", "packet": packet, "packet_clipped": 0,
"notes": "", "posture": "standard", "masking_on": false,
"redaction": []any{}, "prescan": nil, "upstream": "",
}
e, _ := ss("POST", "/estimate", input)
// THE BODY IS THE INPUT OBJECT ITSELF - no "input" wrapper.
String input = "{\"task\":\"cip\",\"packet\":\"...\",\"posture\":\"standard\"}";
String est = Ss.ss("POST", "/estimate", input);
# THE BODY IS THE INPUT OBJECT ITSELF - no "input" wrapper.
input = { "task" => "cip", "packet" => packet, "packet_clipped" => 0,
"notes" => "", "posture" => "standard", "masking_on" => false,
"redaction" => [], "prescan" => nil, "upstream" => "" }
est = ss("POST", "/estimate", input)["data"]
<?php
// THE BODY IS THE INPUT OBJECT ITSELF - no "input" wrapper.
$input = [
"task" => "cip", "packet" => $packet, "packet_clipped" => 0,
"notes" => "", "posture" => "standard", "masking_on" => false,
"redaction" => [], "prescan" => null, "upstream" => "",
];
$est = ss("POST", "/estimate", $input, $TOKEN)["data"];
// THE BODY IS THE INPUT OBJECT ITSELF - no "input" wrapper.
var input = new {
task = "cip", packet, packet_clipped = 0, notes = "",
posture = "standard", masking_on = false,
redaction = new object[0], prescan = (object)null, upstream = ""
};
var est = await Ss.Call(HttpMethod.Post, "/estimate", input);
5. Run it and poll
/run is metered. The response is a job id; poll /jobs/{id} until the status is terminal, then parse output as the lane's JSON envelope.
# /run is METERED. Always send an Idempotency-Key: a retry with the same key
# returns the SAME job instead of billing twice.
curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: kyc-desk:cip:$(printf %s "$PACKET" | shasum | cut -c1-16)" \
-d "$INPUT"
# -> {"data":{"job_id":"job_..."}}
# Poll to a terminal state.
ss GET /jobs/job_...
# -> {"data":{"status":"succeeded","output":"{ ...the JSON envelope... }",
# "charged_credits":1204,"truncated":false}}
import hashlib, time
key = "kyc-desk:%s:%s" % (inp["task"], hashlib.sha256(
(inp["task"] + inp["packet"]).encode()).hexdigest()[:16])
req = urllib.request.Request(BASE + "/run",
data=json.dumps(inp).encode(), method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Idempotency-Key", key) # a retry returns the same job
with urllib.request.urlopen(req) as r:
job = json.loads(r.read())["data"]
while True:
j = ss("GET", "/jobs/" + job["job_id"])["data"]
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(1.5)
envelope = json.loads(j["output"]) # the lane's JSON envelope
print(envelope["lane"], envelope["verdict"]["call"], j["charged_credits"])
const key = `kyc-desk:${input.task}:${await sha16(input.task + input.packet)}`;
const res = await fetch(BASE + "/run", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${TOKEN}`,
"Idempotency-Key": key // a retry returns the same job
},
body: JSON.stringify(input)
});
const { data: job } = await res.json();
let j;
do {
await new Promise(r => setTimeout(r, 1500));
j = (await ss("GET", `/jobs/${job.job_id}`)).data;
} while (!["succeeded", "failed", "cancelled"].includes(j.status));
const envelope = JSON.parse(j.output);
console.log(envelope.lane, envelope.verdict.call, j.charged_credits);
// Send Idempotency-Key on every /run; a retry with the same key returns the
// same job rather than billing a second time.
req, _ := http.NewRequest("POST", base+"/run", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "kyc-desk:cip:"+hash16)
// then poll GET /jobs/{job_id} until status is terminal
// Send Idempotency-Key on every /run.
HttpRequest run = HttpRequest.newBuilder(URI.create(Ss.BASE + "/run"))
.header("Authorization", "Bearer " + Ss.token)
.header("Content-Type", "application/json")
.header("Idempotency-Key", "kyc-desk:cip:" + hash16)
.POST(HttpRequest.BodyPublishers.ofString(input))
.build();
// then poll GET /jobs/{job_id}
uri = URI(BASE.to_s + "/run")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{TOKEN}"
req["Idempotency-Key"] = "kyc-desk:cip:#{hash16}"
req.body = JSON.dump(input)
job = JSON.parse(Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h|
h.request(req)
}.body)["data"]
# then poll GET /jobs/{job_id}
<?php
$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"Authorization: Bearer " . $TOKEN,
"Idempotency-Key: kyc-desk:cip:" . $hash16,
],
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_RETURNTRANSFER => true,
]);
$job = json_decode(curl_exec($ch), true)["data"];
// then poll GET /jobs/{job_id}
var req = new HttpRequestMessage(HttpMethod.Post, "/run");
req.Headers.Add("Idempotency-Key", $"kyc-desk:cip:{hash16}");
// ... set Authorization and Content, send, then poll GET /jobs/{job_id}
6. Or stream it
/run-stream bills identically and delivers the same envelope as Server-Sent Events. This is what the app itself uses.
# /run-stream is the same billing as /run, delivered as Server-Sent Events.
# The app uses it so the progress card can advance on real signals.
curl -sSN -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: kyc-desk:aml:$HASH" \
-d "$INPUT"
# event: job -> {"job_id":"job_..."}
# event: delta -> {"text":"{\"lane\":\"aml\","}
# event: done -> {"charged_credits":1408,"truncated":false}
req = urllib.request.Request(BASE + "/run-stream",
data=json.dumps(inp).encode(), method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Idempotency-Key", key)
buf = []
with urllib.request.urlopen(req) as r:
for raw in r:
line = raw.decode().rstrip()
if line.startswith("data: "):
payload = json.loads(line[6:])
if "text" in payload:
buf.append(payload["text"])
envelope = json.loads("".join(buf))
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${TOKEN}`,
"Idempotency-Key": key
},
body: JSON.stringify(input)
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let raw = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
for (const line of dec.decode(value).split("\n")) {
if (!line.startsWith("data: ")) continue;
const p = JSON.parse(line.slice(6));
if (p.text) raw += p.text;
}
}
const envelope = JSON.parse(raw);
// POST /run-stream and read the response body line by line, accumulating the
// "text" field of every `data:` line into one string, then unmarshal it.
// POST /run-stream with BodyHandlers.ofLines(), keep the lines beginning
// "data: ", concatenate their "text" fields, then parse the result.
# POST /run-stream and read the response in chunks, appending the "text"
# field of every line that begins with "data: ".
<?php
// POST /run-stream with CURLOPT_WRITEFUNCTION and append the "text" field of
// every line beginning "data: " to a buffer, then json_decode the buffer.
// POST /run-stream, read the stream with a StreamReader, and append the
// "text" field of every line beginning "data: " before deserialising.
7. One worked example per lane
The task field selects the lane and is the first thing to get right. Each lane has its own verdict.call enum and its own rows[].status enum; everything else in the envelope is identical across all five.
# ---- task: parse -------------------------------------------------------
ss POST /estimate '{"task":"parse","packet":"<the onboarding packet>","packet_clipped":0,"notes":"","posture":"standard","masking_on":true,"redaction":[{"kind":"PARTY","placeholder":"[PARTY-1]","length":9}],"prescan":{"...":"the browser scan"},"upstream":""}'
# verdict.call is one of: complete | partial | insufficient
# rows[].status is one of: present | not stated | unreadable | contradicted
# ---- task: cip ---------------------------------------------------------
ss POST /estimate '{"task":"cip","packet":"<the onboarding packet>","packet_clipped":0,"notes":"","posture":"standard","masking_on":true,"redaction":[{"kind":"PARTY","placeholder":"[PARTY-1]","length":9}],"prescan":{"...":"the browser scan"},"upstream":""}'
# verdict.call: open | open-with-conditions | do-not-open
# rows[].status: satisfied | missing | inadequate | expired | condition
# ---- task: risk --------------------------------------------------------
ss POST /estimate '{"task":"risk","packet":"<the onboarding packet>","packet_clipped":0,"notes":"","posture":"standard","masking_on":true,"redaction":[{"kind":"PARTY","placeholder":"[PARTY-1]","length":9}],"prescan":{"...":"the browser scan"},"upstream":""}'
# verdict.call: low | medium | high
# rows[].status: major | moderate | minor | mitigated
# ---- task: aml ---------------------------------------------------------
ss POST /estimate '{"task":"aml","packet":"<the onboarding packet>","packet_clipped":0,"notes":"","posture":"standard","masking_on":true,"redaction":[{"kind":"PARTY","placeholder":"[PARTY-1]","length":9}],"prescan":{"...":"the browser scan"},"upstream":""}'
# verdict.call: clear | monitor | escalate
# rows[].status: present | absent | indeterminate
# ---- task: rfi ---------------------------------------------------------
# This lane consumes the previous lane's artifact through `upstream`.
ss POST /estimate '{"task":"rfi","packet":"<the onboarding packet>","packet_clipped":0,"notes":"","posture":"standard","masking_on":true,"redaction":[{"kind":"PARTY","placeholder":"[PARTY-1]","length":9}],"prescan":{"...":"the browser scan"},"upstream":"<the cip lane's artifact markdown>"}'
# verdict.call: ready-to-send | needs-internal-review
# rows[].status: blocking | non-blocking
LANES = {
"parse": ("complete", "partial", "insufficient"),
"cip": ("open", "open-with-conditions", "do-not-open"),
"risk": ("low", "medium", "high"),
"aml": ("clear", "monitor", "escalate"),
"rfi": ("ready-to-send", "needs-internal-review"),
}
ROW_STATES = {
"parse": ("present", "not stated", "unreadable", "contradicted"),
"cip": ("satisfied", "missing", "inadequate", "expired", "condition"),
"risk": ("major", "moderate", "minor", "mitigated"),
"aml": ("present", "absent", "indeterminate"),
"rfi": ("blocking", "non-blocking"),
}
for task in LANES:
inp["task"] = task
if task == "rfi":
inp["upstream"] = cip_artifact # hand the determination across
est = ss("POST", "/estimate", inp)["data"]
print(task, est["hold_credits"]) # the hold differs per lane
const LANES = {
parse: ["complete", "partial", "insufficient"],
cip: ["open", "open-with-conditions", "do-not-open"],
risk: ["low", "medium", "high"],
aml: ["clear", "monitor", "escalate"],
rfi: ["ready-to-send", "needs-internal-review"]
};
for (const task of Object.keys(LANES)) {
const est = (await ss("POST", "/estimate", { ...input, task })).data;
console.log(task, est.hold_credits); // re-estimate on every lane switch
}
for _, task := range []string{"parse", "cip", "risk", "aml", "rfi"} {
input["task"] = task
e, _ := ss("POST", "/estimate", input)
_ = e // hold_credits differs per lane
}
for (String task : new String[]{"parse", "cip", "risk", "aml", "rfi"}) {
// rebuild the body with this task and POST /estimate; the hold differs
}
%w[parse cip risk aml rfi].each do |task|
input["task"] = task
est = ss("POST", "/estimate", input)["data"]
puts "#{task} #{est['hold_credits']}"
end
<?php
foreach (["parse", "cip", "risk", "aml", "rfi"] as $task) {
$input["task"] = $task;
$est = ss("POST", "/estimate", $input, $TOKEN)["data"];
echo $task, " ", $est["hold_credits"], PHP_EOL;
}
foreach (var task in new[] { "parse", "cip", "risk", "aml", "rfi" }) {
var est = await Ss.Call(HttpMethod.Post, "/estimate", new { task /* ... */ });
// hold_credits differs per lane
}
The output envelope
Every lane returns one JSON object with the same eleven keys. What differs between lanes is the
meaning of the rows columns and the two enums.
{
"lane": "cip",
"title": "...",
"verdict": { "call": "open-with-conditions", "label": "...", "why": "..." },
"headline": "...",
"summary": "...",
"facts": [ { "label": "...", "value": "...", "note": "..." } ],
"rows": [ { "key": "...", "label": "...", "status": "...",
"basis": "...", "action": "..." } ],
"sections": [ { "heading": "...", "body": "markdown" } ],
"findings": [ { "id": "1", "title": "...", "severity": "high",
"basis": "...", "quote": "...", "recommendation": "..." } ],
"coverage": [ { "flag_id": "F001", "status": "confirmed", "note": "..." } ],
"questions": [ "..." ],
"artifact": "the document this lane produces, as markdown"
}
coverage carries exactly one entry per flag the browser's prescan raised, so a
scripted caller that passes prescan: null will get an empty
coverage array — correctly, since there was nothing to reconcile.
What this API will not do
- It performs no screening. There is no sanctions, PEP or adverse-media list behind any of these endpoints.
- It makes no regulatory determination and opens no account. Every response is a draft for a human reviewer.
- It does not persist the packet you send. The app's own history stores the review, never the packet.