API Reference
CREDFORT checks payslips and bank statements for tampering, broken arithmetic, missing salary and reuse across applicants. You send documents, we return a decision with the reasons behind it.
All endpoints live under one base URL and speak JSON. Every response carries an X-Request-Id header; quote it when you contact us.
__BASE__
/v1/files./v1/cases./v1/cases/{id} until it completes, usually within a minute.Authentication
Send your API key as a bearer token on every /v1 call.
Authorization: Bearer cf_test_…
| Key prefix | Mode | What happens |
|---|---|---|
cf_test_ | sandbox | Free. Figures are simulated, metadata forensics runs for real on your file. |
cf_live_ | live | Billed per check. Documents are read and checked in full. |
Create keys yourself in the dashboard: add an application for each system that calls CREDFORT, then issue it test or live keys. Keys are shown once and stored only as a hash. Keep them on your server, never in a browser or mobile app. An application can be limited to certain checks and IP addresses.
Sandbox
Sandbox keys never send documents to the extraction model. Instead, they return a realistic payslip or three-month statement for the applicant you name. Put one of these words in the filename to steer the result and test each branch of your integration:
| Filename contains | Result |
|---|---|
_mismatch | Payslip net pay does not reconcile. |
_balancebreak | One statement row's running balance is off. |
_arith | Statement closing balance does not reconcile. |
_sweep | Salary moved out the day after payday. |
_nosalary | No salary credits on the statement. |
_variance | Bank salary 30% below the payslip. |
_stale | Document is four months old. |
_holder | Statement belongs to someone else. |
_tampered | Simulated incremental saves (forensics). |
_locked | Upload refused as password-protected. |
_llmdown | Extraction unavailable; the case completes as PARTIAL. |
Errors
Errors use standard HTTP status codes and one JSON shape. code is stable and safe to branch on; message is for people.
{
"error": {
"type": "invalid_request",
"code": "file_not_found",
"message": "Unknown file_id: file_8f3…",
"field": "files",
"request_id": "req_68c4c84f3cd441d7"
}
}| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_field | A field is missing or malformed; see field. |
| 400 | consent_missing | The case has no valid consent block. |
| 400 | file_not_found | A file_id is unknown to your account. |
| 400 | file_unreadable · file_password_protected | The upload is empty or locked. |
| 401 | key_invalid · key_revoked | Missing, unknown or revoked API key. |
| 403 | module_not_enabled | Your key is not enabled for a requested check. |
| 403 | ip_not_allowed | The request came from outside the key's IP allowlist. |
| 403 | client_suspended · integration_disabled | Access is switched off; contact us. |
| 404 | case_not_found | No such case on your account. |
| 409 | idempotency_mismatch | An Idempotency-Key was reused with a different body. |
| 413 | file_too_large | Files are limited to 20 MB. |
| 422 | unsupported_document_type | Upload a PDF or an image (PNG, JPG, WEBP). |
| 429 | rate_limit · monthly_quota | Slow down, or your monthly live-case quota is used. Honour Retry-After. |
Rate limits
Each key has a requests-per-second limit set by your plan (Starter 5, Growth 50, Enterprise 200 or more). Every response reports where you stand:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed per second. |
X-RateLimit-Remaining | Requests left in the current second. |
Retry-After | On a 429: seconds to wait before retrying. |
Some plans also carry a monthly quota of live cases. Sandbox cases never count toward it.
Idempotency
Send an Idempotency-Key header when creating a case, such as your application ID. If the network drops and you retry with the same key and body, you get the original case back with "replayed": true, and you are not charged twice. The same key with a different body returns 409 idempotency_mismatch.
Upload a file
Upload one payslip or bank statement as multipart/form-data. PDF or image (PNG, JPG, WEBP), up to 20 MB. Keep the returned file_id for the case.
curl __BASE__/v1/files \
-H "Authorization: Bearer $CREDFORT_KEY" \
-F file=@payslip.pdf{
"file_id": "file_3b1d9c0e5a7f42b18c6d",
"filename": "payslip.pdf",
"sha256": "9f2c…",
"size_bytes": 48211,
"encrypted": false
}encrypted is true for a password-protected PDF. We try the applicant's ID number as the password when the case runs.
Create a case
Run checks on one applicant and up to ten files. The case runs in the background: you get 202 straight away, then poll Get a case.
| Field | Description |
|---|---|
subject.full_namestring · optional | Applicant's name, matched against the documents. |
subject.id_numberstring · optional | SA ID number. Needed for the reuse lookup and to open locked PDFs. Only an HMAC fingerprint is stored. |
subject.account_numberstring · optional | Matched against the statement; only the last 4 digits are kept. |
subject.employerstring · optional | Expected employer on the payslip. |
files[]array · required | 1–10 items of {file_id, document_type}. document_type is PAYSLIP or BANK_STATEMENT. |
files[].figuresobject · optional | Your own extracted figures. When present we skip extraction for that file. |
consent.referencestring · required | Your reference for the applicant's POPIA consent. |
consent.captured_atdatetime · required | When consent was given (ISO 8601). |
consent.channelstring · optional | Where it was captured, e.g. web, branch. |
modulesstring[] · optional | Checks to run. Defaults to all your key allows; see modules. |
external_refstring · optional | Your application or loan ID, shown on the case. |
curl -X POST __BASE__/v1/cases \ -H "Authorization: Bearer $CREDFORT_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: loan-10482" \ -d '{ "external_ref": "loan-10482", "subject": { "full_name": "Thandi Mokoena", "id_number": "9001015009087", "employer": "Sandbox Holdings" }, "files": [ {"file_id": "file_3b1d…", "document_type": "PAYSLIP"}, {"file_id": "file_a07c…", "document_type": "BANK_STATEMENT"} ], "consent": { "reference": "consent-77812", "captured_at": "2026-11-10T09:30:00Z", "channel": "web" } }'
{
"case_id": "case_5e0f2a9d1c8b47e6a3f1",
"status": "RECEIVED",
"modules_pending": 4,
"poll_interval_seconds": 2
}Get a case
Poll every couple of seconds until status is COMPLETED, PARTIAL or FAILED. Before that it is RECEIVED or PROCESSING and result is null.
{
"case_id": "case_5e0f2a9d1c8b47e6a3f1",
"external_ref": "loan-10482",
"status": "COMPLETED",
"decision": "FAIL",
"risk_score": 60,
"rule_version": 1,
"sandbox": true,
"charge": 0.0,
"result": {
"modules": {
"doc.integrity": {
"status": "COMPLETED",
"outcome": "FAIL",
"note": null,
"signals": [{
"code": "STATEMENT_RUNNING_BALANCE_BREAK",
"severity": "HARD",
"module": "doc.integrity",
"file_id": "file_a07c…",
"message": "After the 9 Sep transaction the balance should be R 12 058.28, but the statement shows R 13 558.28.",
"evidence": { … }
}]
},
"doc.forensics": { "status": "COMPLETED", "outcome": "PASS", "signals": [] }
},
"files": [ { "file_id": "file_a07c…", "figures": { … } } ],
"charge": { "currency": "ZAR", "list_total": 38.0, "billed": 0.0, "lines": [ … ] }
},
"created_at": "2026-11-10T09:31:02Z",
"completed_at": "2026-11-10T09:31:20Z"
}Each module reports its own status: COMPLETED, SKIPPED (for example, no ID number for the reuse lookup; note says why) or UPSTREAM_UNAVAILABLE (extraction was down; that module counts as REVIEW and the case is PARTIAL). Only completed modules are billed.
List cases
Your most recent cases, newest first, without the result body. limit is 1–100, default 25.
Report an outcome
Tell us what you decided. outcome is one of approved, declined, confirmed_fraud or false_positive. Confirmed fraud records the case's document fingerprints, so the same documents fail if they are used again. Posting outcomes on most cases also earns the contributor discount.
curl -X POST __BASE__/v1/cases/case_5e0f…/outcome \ -H "Authorization: Bearer $CREDFORT_KEY" \ -H "Content-Type: application/json" \ -d '{"outcome": "confirmed_fraud"}'
{
"case_id": "case_5e0f…",
"outcome": "confirmed_fraud",
"fingerprints_reported": 3
}One-shot forensics
Metadata and tamper forensics on a single file, answered synchronously, with no case. Send file and optionally document_type (PAYSLIP by default) as form fields.
curl __BASE__/v1/checks/doc-forensics \
-H "Authorization: Bearer $CREDFORT_KEY" \
-F file=@statement.pdf \
-F document_type=BANK_STATEMENT{
"module": "doc.forensics",
"outcome": "REVIEW",
"sha256": "c41e…",
"signals": [ … ],
"details": { … },
"charge": { "currency": "ZAR", "billed": 0.0 }
}Rules
Your rule set decides thresholds and how severe each reason code is. Defaults match how CREDFORT lends today. Each PUT replaces the whole set and creates a new version; every case records the rule_version it ran under.
| Rule | Default | Meaning |
|---|---|---|
payslip_max_age_days | 40 | Older payslips are flagged as stale. |
statement_max_age_days | 31 | Same for statements. |
min_statement_months | 3 | Months of statement history required. |
arithmetic_tolerance_rand | 5.0 | Rand difference allowed before a sum counts as broken. |
salary_variance_tolerance_pct | 15.0 | Allowed gap between payslip net pay and the bank salary credit. |
salary_sweep_tolerance_pct | 10.0 | A debit soon after payday within this % of the salary counts as the salary being swept out. |
min_salary_months | 3 | Months that must show a salary credit. |
incremental_saves_hard | 3 | Save generations at which tampering becomes HARD. |
bank_modified_after_creation_seconds | 3600 | Edit window after issue before a statement counts as modified. |
bank_producer_check | true | Compare the PDF producer with the bank's known software. |
mismatch_strikes_to_block | 2 | Arithmetic mismatches on one applicant before a HARD block. |
auto_block | true | On that block, file a fraud report so later cases fail. |
severity_overrides | {} | Map of reason code to HARD, SOFT or INFO. |
statement_severity_overrides | … | Same, for statements only. |
Usage
Metered checks by day, module and mode, with list amounts. Sandbox rows have an amount of 0.
{ "data": [
{ "date": "2026-11-10", "module": "doc.integrity", "sandbox": false, "units": 42, "list_amount": 504.0 }
] }Billing
Your running invoice for this month, or another month. It shows usage at list price, then tier, volume and contributor discounts, the platform fee, included credit and 15% VAT. Sandbox usage is never billed.
Decisions & scores
Every finding is a signal with a reason code and a severity:
| Severity | Effect | Risk points |
|---|---|---|
| HARD | Fails its module. | 60 |
| SOFT | Sends its module to review. | 15 |
| INFO | Recorded as evidence only. | 0 |
A module with no HARD or SOFT signals passes. The case decision is the worst module outcome: FAIL over REVIEW over PASS. risk_score adds up the points, capped at 100. The decision is evidence for your credit team; the lending decision stays yours.
Reason codes
Checks are grouped into four modules. Pass any of them in modules:
| Module | What it checks |
|---|---|
doc.forensics | Incremental saves, editing tools, changes after issue, stripped dates, flattening, odd fonts, producer vs bank. |
doc.integrity | Payslip and statement arithmetic, running balances, PAYE/UIF, tax number, freshness, holder and account match. |
income.verify | Salary credit present and monthly, payslip vs bank variance, salary swept after payday. |
consortium.lookup | The same file, tax number or statement totals used under another applicant, and earlier confirmed fraud. |
Every code
| Code | Meaning |
|---|---|
| Loading reason codes… | |
Catalogue & pricing
These need no key. /v1/catalogue returns the modules and every reason code above. /v1/pricing returns per-check prices in ZAR excluding VAT, plus plan terms. /health is a liveness check.
Prefer a machine-readable spec? The OpenAPI document describes every endpoint here.