Reference
Usage & credits
Per-workflow usage aggregates and the paginated credit ledger — reconcile spend and monitor the prepaid balance.
Obsidian bills verification work against a prepaid credit balance. These two
/v3 read endpoints expose spend two ways: a rolled-up view grouped by
workflow, and the raw ledger of every individual debit and top-up. Use the
aggregate for dashboards and the ledger for line-by-line reconciliation.
Info
Both endpoints are on the /v3 surface and authenticate with x-api-key.
Errors carry { "detail": "…" }. See
Core concepts → Credits for the billing model.
Workflow usage
GET /v3/usage/workflows/
x-api-key: <tenant_api_key>Aggregated credit spend and billed-entry counts per workflow. The server sums
every metered debit (negative ledger rows tied to a session) and groups by the
session's workflow name. Sessions not linked to a named workflow roll up
under "-". There are no query parameters — the response covers the tenant's
full history.
curl -s https://acme.thirdfactor.ai/v3/usage/workflows/ \
-H "x-api-key: $OBSIDIAN_API_KEY"const resp = await fetch("https://acme.thirdfactor.ai/v3/usage/workflows/", {
headers: { "x-api-key": process.env.OBSIDIAN_API_KEY },
});
const { usage } = await resp.json();
const total = usage.reduce((sum, row) => sum + row.credits, 0);import requests
resp = requests.get(
"https://acme.thirdfactor.ai/v3/usage/workflows/",
headers={"x-api-key": OBSIDIAN_API_KEY},
)
usage = resp.json()["usage"]
total = sum(row["credits"] for row in usage)Response Example
{
"usage": [
{ "workflow": "Age check only", "credits": 210.0, "entries": 210 },
{ "workflow": "Standard onboarding", "credits": 1240.0, "entries": 310 },
{ "workflow": "-", "credits": 12.0, "entries": 4 }
]
}usagearrayworkflowstringWorkflow name. "-" groups sessions with no linked/named workflow.
creditsnumberTotal credits consumed by that workflow (rounded to 4 decimals). Always a positive magnitude — the underlying debits are negative ledger deltas.
entriesintegerNumber of billed session-debit rows attributed to the workflow.
Errors
| Status | Code | When |
|---|---|---|
401 | invalid API key | Missing or wrong x-api-key. |
429 | rate-limited | Over the kyc_v3_read bucket. |
Credit ledger
GET /v3/credits/ledger/
x-api-key: <tenant_api_key>The raw, paginated ledger of every credit movement — session debits, per-module debits, tool usage, refunds, top-ups, and manual adjustments — newest first.
sincestringqueryStart date (inclusive), YYYY-MM-DD. A malformed value returns
400 { "detail": "since must be YYYY-MM-DD." }.
untilstringqueryEnd date (inclusive), YYYY-MM-DD. A malformed value returns
400 { "detail": "until must be YYYY-MM-DD." }.
pageintegerqueryDefault: 1Page number. Values below 1 are clamped to 1.
page_sizeintegerqueryDefault: 100Rows per page, clamped to 1..500.
curl -s "https://acme.thirdfactor.ai/v3/credits/ledger/?since=2026-07-01&until=2026-07-31&page=1&page_size=100" \
-H "x-api-key: $OBSIDIAN_API_KEY"const params = new URLSearchParams({
since: "2026-07-01",
until: "2026-07-31",
page: "1",
page_size: "100",
});
const resp = await fetch(
`https://acme.thirdfactor.ai/v3/credits/ledger/?${params}`,
{ headers: { "x-api-key": process.env.OBSIDIAN_API_KEY } },
);
const ledger = await resp.json();resp = requests.get(
"https://acme.thirdfactor.ai/v3/credits/ledger/",
headers={"x-api-key": OBSIDIAN_API_KEY},
params={"since": "2026-07-01", "until": "2026-07-31", "page": 1, "page_size": 100},
)
ledger = resp.json()Response Example
{
"count": 128,
"page": 1,
"page_size": 100,
"results": [
{
"id": "3f7a1c9e-2b4d-4e6f-8a1b-0c2d3e4f5a6b",
"delta": "-3.0000",
"reason": "session_create",
"feature": "SESSION_CREATE",
"session_id": "6f0e2b7a-1c2d-4e5f-8a9b-0c1d2e3f4a5b",
"application_id": "",
"created_at": "2026-07-03T08:00:00Z"
},
{
"id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"delta": "-1.0000",
"reason": "kyc_module",
"feature": "AML_SCREENING",
"session_id": "6f0e2b7a-1c2d-4e5f-8a9b-0c1d2e3f4a5b",
"application_id": "",
"created_at": "2026-07-03T08:12:00Z"
},
{
"id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e",
"delta": "5000.0000",
"reason": "topup",
"feature": "",
"session_id": "",
"application_id": "",
"created_at": "2026-07-01T00:00:00Z"
}
]
}countintegerpageintegerpage_sizeinteger1..500).resultsarrayEach row in results:
idstringdeltastringSigned credit change as a decimal string (e.g. "-3.0000" for a debit,
"5000.0000" for a top-up). Parse it as a number before arithmetic.
reasonstringMovement type: session_create, kyc_session (legacy per-session lump),
kyc_module (per-feature metered), tool_usage, refund, topup, or
adjust.
featurestringThe chargeable feature key for usage rows (e.g. AML_SCREENING,
FACE_SEARCH). Blank on non-usage rows like topup/adjust.
session_idstringapplication_idstringcreated_atstringErrors
| Status | Code | When |
|---|---|---|
400 | since must be YYYY-MM-DD. / until must be YYYY-MM-DD. | Malformed date filter. |
401 | invalid API key | Missing or wrong x-api-key. |
429 | rate-limited | Over the kyc_v3_read bucket. |
Reconciliation example
Page through the ledger for a month and sum debits by feature to reconcile against your own invoice or internal usage counters.
async function reconcile(since, until) {
const byFeature = {};
let page = 1;
for (;;) {
const params = new URLSearchParams({ since, until, page: String(page), page_size: "500" });
const resp = await fetch(
`https://acme.thirdfactor.ai/v3/credits/ledger/?${params}`,
{ headers: { "x-api-key": process.env.OBSIDIAN_API_KEY } },
);
const { results, count, page_size } = await resp.json();
for (const row of results) {
const delta = parseFloat(row.delta);
if (delta < 0) {
const key = row.feature || row.reason;
byFeature[key] = (byFeature[key] || 0) + -delta;
}
}
if (page * page_size >= count) break;
page += 1;
}
return byFeature;
}
console.log(await reconcile("2026-07-01", "2026-07-31"));import requests
def reconcile(since, until):
by_feature, page = {}, 1
while True:
resp = requests.get(
"https://acme.thirdfactor.ai/v3/credits/ledger/",
headers={"x-api-key": OBSIDIAN_API_KEY},
params={"since": since, "until": until, "page": page, "page_size": 500},
)
body = resp.json()
for row in body["results"]:
delta = float(row["delta"])
if delta < 0:
key = row["feature"] or row["reason"]
by_feature[key] = by_feature.get(key, 0.0) + -delta
if page * body["page_size"] >= body["count"]:
break
page += 1
return by_feature
print(reconcile("2026-07-01", "2026-07-31"))Aggregate vs. ledger
GET /v3/usage/workflows/ | GET /v3/credits/ledger/ | |
|---|---|---|
| Granularity | Rolled up per workflow | One row per movement |
| Includes top-ups / refunds | ❌ — debits only | ✅ — all movements |
| Date filtering | ❌ — full history | ✅ — since / until |
| Pagination | ❌ — small, returned whole | ✅ — page / page_size |
| Best for | Dashboards, spend-by-product | Line-by-line reconciliation, audits |
Note
Creating a session debits the SESSION_CREATE rate; a 402 insufficient_credits
response on session create means the balance is exhausted. Top up before the
balance runs out — monitor it with the ledger's running topup/adjust rows.
See Core concepts → Credits.
Common pitfalls
Warning
Treating delta as a number. It is a decimal string ("-3.0000").
parseFloat / float() it before summing, or your totals silently concatenate
or throw.
Warning
Assuming the aggregate matches the ledger sum. usage/workflows counts only
session-linked debits; it excludes top-ups, refunds, and adjustments. To tie out
the exact balance movement, sum the ledger, not the aggregate.