CREDFORTAPI Reference

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 URL
__BASE__
UploadSend each payslip or statement to /v1/files.
Create a caseName the applicant and the files at /v1/cases.
PollRead /v1/cases/{id} until it completes, usually within a minute.
Report backPost the lending outcome so reuse checks get sharper.

Authentication

Send your API key as a bearer token on every /v1 call.

Header
Authorization: Bearer cf_test_…
Key prefixModeWhat happens
cf_test_sandboxFree. Figures are simulated, metadata forensics runs for real on your file.
cf_live_liveBilled 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.

No account yet? Request sandbox access. Once we approve it, we email you a link to set your password and sign in.

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 containsResult
_mismatchPayslip net pay does not reconcile.
_balancebreakOne statement row's running balance is off.
_arithStatement closing balance does not reconcile.
_sweepSalary moved out the day after payday.
_nosalaryNo salary credits on the statement.
_varianceBank salary 30% below the payslip.
_staleDocument is four months old.
_holderStatement belongs to someone else.
_tamperedSimulated incremental saves (forensics).
_lockedUpload refused as password-protected.
_llmdownExtraction unavailable; the case completes as PARTIAL.
Use made-up applicants in sandbox, not real ID numbers or real documents.

Errors

Errors use standard HTTP status codes and one JSON shape. code is stable and safe to branch on; message is for people.

Error body
{
  "error": {
    "type": "invalid_request",
    "code": "file_not_found",
    "message": "Unknown file_id: file_8f3…",
    "field": "files",
    "request_id": "req_68c4c84f3cd441d7"
  }
}
StatusCodeMeaning
400invalid_fieldA field is missing or malformed; see field.
400consent_missingThe case has no valid consent block.
400file_not_foundA file_id is unknown to your account.
400file_unreadable · file_password_protectedThe upload is empty or locked.
401key_invalid · key_revokedMissing, unknown or revoked API key.
403module_not_enabledYour key is not enabled for a requested check.
403ip_not_allowedThe request came from outside the key's IP allowlist.
403client_suspended · integration_disabledAccess is switched off; contact us.
404case_not_foundNo such case on your account.
409idempotency_mismatchAn Idempotency-Key was reused with a different body.
413file_too_largeFiles are limited to 20 MB.
422unsupported_document_typeUpload a PDF or an image (PNG, JPG, WEBP).
429rate_limit · monthly_quotaSlow 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:

HeaderMeaning
X-RateLimit-LimitRequests allowed per second.
X-RateLimit-RemainingRequests left in the current second.
Retry-AfterOn 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

POST/v1/files

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.

Request
curl __BASE__/v1/files \
  -H "Authorization: Bearer $CREDFORT_KEY" \
  -F file=@payslip.pdf
Response · 201
{
  "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

POST/v1/cases

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.

FieldDescription
subject.full_name
string · optional
Applicant's name, matched against the documents.
subject.id_number
string · optional
SA ID number. Needed for the reuse lookup and to open locked PDFs. Only an HMAC fingerprint is stored.
subject.account_number
string · optional
Matched against the statement; only the last 4 digits are kept.
subject.employer
string · 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[].figures
object · optional
Your own extracted figures. When present we skip extraction for that file.
consent.reference
string · required
Your reference for the applicant's POPIA consent.
consent.captured_at
datetime · required
When consent was given (ISO 8601).
consent.channel
string · optional
Where it was captured, e.g. web, branch.
modules
string[] · optional
Checks to run. Defaults to all your key allows; see modules.
external_ref
string · optional
Your application or loan ID, shown on the case.
Request
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"
  }
}'
Response · 202
{
  "case_id": "case_5e0f2a9d1c8b47e6a3f1",
  "status": "RECEIVED",
  "modules_pending": 4,
  "poll_interval_seconds": 2
}

Get a case

GET/v1/cases/{case_id}

Poll every couple of seconds until status is COMPLETED, PARTIAL or FAILED. Before that it is RECEIVED or PROCESSING and result is null.

Response · 200
{
  "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

GET/v1/cases?limit=25

Your most recent cases, newest first, without the result body. limit is 1–100, default 25.

Report an outcome

POST/v1/cases/{case_id}/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.

Request
curl -X POST __BASE__/v1/cases/case_5e0f…/outcome \
  -H "Authorization: Bearer $CREDFORT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"outcome": "confirmed_fraud"}'
Response · 200
{
  "case_id": "case_5e0f…",
  "outcome": "confirmed_fraud",
  "fingerprints_reported": 3
}

One-shot forensics

POST/v1/checks/doc-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.

Request
curl __BASE__/v1/checks/doc-forensics \
  -H "Authorization: Bearer $CREDFORT_KEY" \
  -F file=@statement.pdf \
  -F document_type=BANK_STATEMENT
Response · 200
{
  "module": "doc.forensics",
  "outcome": "REVIEW",
  "sha256": "c41e…",
  "signals": [ … ],
  "details": { … },
  "charge": { "currency": "ZAR", "billed": 0.0 }
}

Rules

GET/v1/rules
PUT/v1/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.

RuleDefaultMeaning
payslip_max_age_days40Older payslips are flagged as stale.
statement_max_age_days31Same for statements.
min_statement_months3Months of statement history required.
arithmetic_tolerance_rand5.0Rand difference allowed before a sum counts as broken.
salary_variance_tolerance_pct15.0Allowed gap between payslip net pay and the bank salary credit.
salary_sweep_tolerance_pct10.0A debit soon after payday within this % of the salary counts as the salary being swept out.
min_salary_months3Months that must show a salary credit.
incremental_saves_hard3Save generations at which tampering becomes HARD.
bank_modified_after_creation_seconds3600Edit window after issue before a statement counts as modified.
bank_producer_checktrueCompare the PDF producer with the bank's known software.
mismatch_strikes_to_block2Arithmetic mismatches on one applicant before a HARD block.
auto_blocktrueOn 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

GET/v1/usage

Metered checks by day, module and mode, with list amounts. Sandbox rows have an amount of 0.

Response · 200
{ "data": [
  { "date": "2026-11-10", "module": "doc.integrity", "sandbox": false, "units": 42, "list_amount": 504.0 }
] }

Billing

GET/v1/billing?month=YYYY-MM

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:

SeverityEffectRisk points
HARDFails its module.60
SOFTSends its module to review.15
INFORecorded 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:

ModuleWhat it checks
doc.forensicsIncremental saves, editing tools, changes after issue, stripped dates, flattening, odd fonts, producer vs bank.
doc.integrityPayslip and statement arithmetic, running balances, PAYE/UIF, tax number, freshness, holder and account match.
income.verifySalary credit present and monthly, payslip vs bank variance, salary swept after payday.
consortium.lookupThe same file, tax number or statement totals used under another applicant, and earlier confirmed fraud.

Every code

CodeMeaning
Loading reason codes…

Catalogue & pricing

GET/v1/catalogue
GET/v1/pricing
GET/health

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.