← KYC Desk / API

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

HTTPcodeWhat it means
400VALIDATION_ERRORThe 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.
401UNAUTHORIZEDNo token, or a token for a different app. Mint one at tokens.html.
402INSUFFICIENT_CREDITSThe balance is below min_credits. Compare /me against /estimate before calling /run.
404NOT_FOUNDWrong path, or a job id from another app.
409CONFLICTAn Idempotency-Key was reused with a different body. Change the key or send the original body.
429RATE_LIMITEDBack off and retry. Vector search is 30/min per IP; the other data endpoints share 120/min.
503UNAVAILABLEA 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
}

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.

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.

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.

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}}

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}

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

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