Concepts
Credits
The credit model — the SESSION_CREATE fee, per-module rates, the 402 insufficient_credits response, and the usage and ledger endpoints.
Obsidian bills in credits. A session debits a fixed session-creation fee plus the rate of each chargeable module it runs. Balances and rates are per-tenant; you watch them through two read endpoints.
Think of credits as the one piece of "runtime cost" your integration needs to
reason about explicitly. Everything else — which modules run, at what
threshold, against which watchlists — is console configuration you consume
passively. Credits are different: a 402 is a real failure mode your
applicant hits mid-flow, so it's worth understanding the model well enough to
alert before it happens, not just handle it when it does.
What gets charged
There are two kinds of debit:
SESSION_CREATE— a platform fee charged once when a hosted session is minted (viaPOST /v3/session/orPOST /api/v1/sessions/). It is not gateable: hosted-session creation is core to the tenant.- Per-module rates — each chargeable module the workflow runs debits its own
configured rate, keyed by
node_idso a repeated module is charged per instance.
Every chargeable feature costs 1 credit by default, unless a different rate is configured globally or per-tenant. There is no separate "free tier" baked into the code — the effective price of each module is whatever rate your tenant has, and the console builder shows it as you assemble a workflow.
Note
Rates are configurable per feature, globally and per-tenant, so the exact price of a module depends on your commercial agreement. Read the effective prices from the console builder or infer real spend from the ledger — do not hard-code prices in your integration.
Chargeable features
Modules and tools that carry a rate, grouped by category:
| Category | Features |
|---|---|
| KYC modules | ID_VERIFICATION, NFC_VERIFICATION, LIVENESS, FACE_MATCH, PROOF_OF_ADDRESS, DOCUMENT_AI, AML_SCREENING, VIDEO_CALL, QUESTIONNAIRE, EMAIL_VERIFICATION, PHONE_VERIFICATION, DATABASE_VALIDATION, IP_ANALYSIS, AGE_ESTIMATION |
| Liveness methods | PASSIVE, PASSIVE_VIDEO, FLASHING, 3D_ACTION, GESTURE — override the base LIVENESS rate |
| Console / standalone tools | FACE_SEARCH, FACE_VERIFY, SIGNATURE_VERIFY, SIGNATURE_SEARCH, AML_CHECK, IMAGE_FORENSICS, IP_CHECK |
| Platform | SESSION_CREATE |
A specific liveness method (FLASHING, 3D_ACTION, …) can carry its own rate
that overrides the base LIVENESS key, the same way
ID_VERIFICATION_INTERNATIONAL overrides ID_VERIFICATION for documents routed
through the hosted international engine.
Tip
Because a repeated node bills per node_id, a graph workflow with two
ID_VERIFICATION nodes debits ID_VERIFICATION's rate twice — once per
instance — even though it's "one workflow." Read a workflow's features array
(see Workflows) before you assume a flat per-session
cost.
Running out of credits
If the balance can't cover a debit, the create call fails with HTTP 402:
{ "detail": "insufficient_credits" }The standalone tools apply the same gate before running:
a tool your tenant is not entitled to returns 403 feature_not_enabled, and one
you can't afford returns 402 insufficient_credits.
const res = await fetch(`${BASE}/v3/session/`, {
method: "POST",
headers: { "x-api-key": process.env.TF_API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ workflow_id }),
});
if (res.status === 402) {
// Applicant hit a dead end — alert ops, don't just show a generic error.
await notifyBillingTeam("insufficient_credits", { tenant: TENANT_ID });
}resp = requests.post(f"{BASE}/v3/session/", headers=headers, json={"workflow_id": workflow_id})
if resp.status_code == 402:
# Applicant hit a dead end — alert ops, don't just show a generic error.
notify_billing_team("insufficient_credits", tenant=TENANT_ID)Warning
A 402 in production means an applicant hit a dead end. Alert when
credits_remaining crosses a floor, not when the first 402 fires.
Idempotent retries are never double-charged
Send an Idempotency-Key header on POST /v3/session/ (or reuse the jti on
POST /api/v1/sessions/). A repeat create with the same key returns the original
session and is not charged again — safe for retries and at-least-once job queues.
Idempotency-Key: <your-unique-key>const idempotencyKey = crypto.randomUUID();
async function createSessionWithRetry(payload, attempt = 0) {
try {
const res = await fetch(`${BASE}/v3/session/`, {
method: "POST",
headers: {
"x-api-key": process.env.TF_API_KEY,
"Idempotency-Key": idempotencyKey,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
return await res.json();
} catch (err) {
if (attempt < 3) return createSessionWithRetry(payload, attempt + 1);
throw err;
}
}import uuid
idempotency_key = str(uuid.uuid4())
def create_session_with_retry(payload, attempt=0):
try:
resp = requests.post(
f"{BASE}/v3/session/",
headers={"x-api-key": TF_API_KEY, "Idempotency-Key": idempotency_key},
json=payload,
)
return resp.json()
except requests.RequestException:
if attempt < 3:
return create_session_with_retry(payload, attempt + 1)
raiseUsage and ledger
Two /v3 reads let you monitor spend. Both are tenant-scoped by the API key.
curl -H "x-api-key: $TF_API_KEY" "$BASE/v3/usage/workflows/"curl -H "x-api-key: $TF_API_KEY" \
"$BASE/v3/credits/ledger/?since=2026-07-01&until=2026-07-31&page_size=200"const res = await fetch(
`${BASE}/v3/credits/ledger/?since=2026-07-01&until=2026-07-31&page_size=200`,
{ headers: { "x-api-key": process.env.TF_API_KEY } }
);
const { count, results } = await res.json();
const spend = results.reduce((sum, row) => sum + Math.abs(parseFloat(row.delta)), 0);resp = requests.get(
f"{BASE}/v3/credits/ledger/",
headers={"x-api-key": os.environ["TF_API_KEY"]},
params={"since": "2026-07-01", "until": "2026-07-31", "page_size": 200},
)
body = resp.json()
spend = sum(abs(float(row["delta"])) for row in body["results"])GET /v3/usage/workflows/ aggregates debits per workflow:
{
"usage": [
{ "workflow": "Enhanced", "credits": 128.0, "entries": 640 },
{ "workflow": "FreeKYC", "credits": 40.0, "entries": 200 }
]
}GET /v3/credits/ledger/ returns the raw debit rows, newest first. It accepts
?since= / ?until= (YYYY-MM-DD, 400 on a bad date) and ?page= /
?page_size= (max 500, default 100):
{
"count": 1240,
"page": 1,
"page_size": 100,
"results": [
{
"id": "…",
"delta": "-1.0000",
"reason": "session_create",
"feature": "SESSION_CREATE",
"session_id": "6f0e2b7a-…",
"application_id": "",
"created_at": "2026-07-03T08:00:00Z"
}
]
}deltastringfeaturestringsession_idstringapplication_idstringTip
POST /api/v1/sessions/ also returns credits_remaining in its response — the
cheapest balance check, since you are already making the call.
Usage vs ledger — which one to use
GET /v3/usage/workflows/ | GET /v3/credits/ledger/ | |
|---|---|---|
| Shape | Aggregated totals per workflow | Raw, individually-attributable debit rows |
| Best for | A dashboard widget, a monthly spend summary | Reconciling a specific session's or application's exact charge, auditing |
| Filterable by date | No | Yes — ?since= / ?until= |
| Paginated | No | Yes — ?page= / ?page_size= |
| Cheapest way to answer | "How much did the Enhanced workflow cost us this month?" | "Exactly what was this $6f0e2b7a session billed for?" |
Edge cases
- A session that never completes still debits
SESSION_CREATE. The platform fee is charged on create, not on completion — an abandoned or expired session still shows asession_createledger row even though no module ever ran. - Concurrent creates racing the same balance. Debits are atomic per
request; two concurrent creates against a balance that can only cover one
will not both succeed — the loser gets
402. Design your retry logic to treat a402as "stop," not "retry immediately." - A
402on a standalone tool vs on session create. Both return402 insufficient_credits, but a tool call (/docs/kyc/tools) never produced a session at all — there's no partial session to clean up. A session, by contrast, only ever gets gated by402at create time (theSESSION_CREATEdebit); once a session exists, per-module billing happens at completion and never blocks the applicant — see the next point. - Credits run out mid-session, after create. Per-module credits are debited when the session finalizes, one row per module that actually ran. If the tenant's balance can't cover a module's rate at that point, the applicant is never blocked or held for review because of it — the debit for that module is simply skipped (and logged), the session's billing stays incomplete, and a later balance top-up retro-bills the remaining modules. Don't assume "session completed" implies "session fully billed" if a tenant's balance was thin.
- Malformed date filters.
?since=/?until=on the ledger expectYYYY-MM-DD; anything else returns400, not an empty result set.
Warning
Common pitfall: budgeting only for SESSION_CREATE and forgetting that
liveness methods and international document routing can override a
module's base rate. Read the actual features a workflow will run (see
Workflows) rather than assuming every
session costs the same regardless of which workflow it uses.
Warning
Common pitfall: reacting to the first 402 in production instead of
alerting on a low-balance threshold. By the time a create call 402s, an
applicant is already mid-journey with nowhere to go. Poll
credits_remaining (from POST /api/v1/sessions/) or the ledger/usage
endpoints and alert well before zero.
FAQ
Related
Workflows
See exactly which modules a workflow will run — and therefore bill — before you create a session.
Sessions and applications
Where credits_charged shows up on a session, and how idempotent create
works end to end.
Tenancy
Credit balances and rates are per-tenant configuration, same as workflows and branding.