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 (via POST /v3/session/ or POST /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_id so 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:

CategoryFeatures
KYC modulesID_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 methodsPASSIVE, PASSIVE_VIDEO, FLASHING, 3D_ACTION, GESTURE — override the base LIVENESS rate
Console / standalone toolsFACE_SEARCH, FACE_VERIFY, SIGNATURE_VERIFY, SIGNATURE_SEARCH, AML_CHECK, IMAGE_FORENSICS, IP_CHECK
PlatformSESSION_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)
        raise

Usage 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"
    }
  ]
}
deltastring
Signed credit change — negative for a debit.
featurestring
The chargeable feature key that produced the row.
session_idstring
Linked session, when the debit came from one.
application_idstring
Linked application, when the debit came from a flow.

Tip

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/
ShapeAggregated totals per workflowRaw, individually-attributable debit rows
Best forA dashboard widget, a monthly spend summaryReconciling a specific session's or application's exact charge, auditing
Filterable by dateNoYes — ?since= / ?until=
PaginatedNoYes — ?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 a session_create ledger 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 a 402 as "stop," not "retry immediately."
  • A 402 on a standalone tool vs on session create. Both return 402 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 by 402 at create time (the SESSION_CREATE debit); 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 expect YYYY-MM-DD; anything else returns 400, 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