Fincheck

Upgrade to v2

Move an existing v1 integration to API v2.

This guide is for integrations that call /api/v1 today. New integrations should start with the Quickstart.

What's new in v2

  • Bearer API keys. Every request authenticates with Authorization: Bearer fce_…. api_token in the body or query string no longer authenticates.
  • One authentication error. A rejected key returns HTTP 401 with a JSON error on every endpoint, instead of a 404 array on offer and accept.
  • Consistent JSON errors. Every error is JSON, with the same shapes on every endpoint, whatever Accept header you send.
  • Optional email. email is no longer required.
  • Stricter phone numbers. cell_phone_number must be a South African mobile number, in any common written form, and is stored in one format.
  • Validated targeted referrals. Leads sent with partner_ids are now validated like any other lead, capped at 50 partners, and the response reports each referral.
  • Duplicate handling. Sending the same applicant again returns the lead you already have, or creates a new one that only goes to partners the applicant has not been sent to. Responses say which with result, and you only ever see your own leads. See Duplicate Submissions.
  • Typed offer fields. rank and probability are integers, probability_conditions is an array, every match has the same flat keys, and each value appears once.
  • Typed requests. POST /lead and POST /offer take each field in its JSON type and name any field sent with the wrong one.
  • Private lead lookups. GET /lead/{hashid} and the partner link only return your own leads.
  • Stable error codes. Every error body has a string code (for example lead_already_accepted) and a message; validation errors list a code and message for each problem on each field. See Errors.
  • Safer accepts. partner_id is validated, and a lead can only be accepted in a state that allows it.

Deprecations

  • api_token authentication is removed.
  • The singular partner_id on POST /lead and POST /offer is rejected. Use partner_ids.
  • company_logo_path, live_score, and userable are no longer returned in offers. Use company_logo_url, live_quotes, and the top-level partner_type and probability_conditions.
  • live_score on POST /accept is rejected. Use live_quotes.
  • The integer code (a copy of the HTTP status) is no longer returned. Error bodies carry a string code instead; success bodies have none.
  • The live-score marker -2 is gone. Partners whose live score is unavailable are left out of matches.

Upgrade your integration

1. Request v2 credentials

Email support@finch-technologies.com for a UAT key and, once you are ready, a Production key. See Environments.

2. Change the complete base URL

Do not keep the v1 host and change only the path. Use the complete base URL for the environment:

EnvironmentBase URL
UAThttps://api.engine-v2.uat.fincheck.co.za/api/v2
Productionhttps://api.engine-v2.fincheck.co.za/api/v2

The operation names stay the same; see the endpoint mapping.

3. Switch to Bearer authentication

Before (v1):

HTTP
POST /api/v1/lead?api_token=your_v1_token
Content-Type: application/json

After (v2):

HTTP
POST /api/v2/lead
Authorization: Bearer fce_your_key
Content-Type: application/json

Use referee_id in the body when a lead must be attributed to another affiliate.

4. Update lead payloads

POST /lead, POST /offer, and each row of POST /lead/batch share these fields. Rows marked Changed or Removed need your attention.

On POST /lead and POST /offer, send the body as Content-Type: application/json and each field in its JSON type: numbers for money (decimals are rounded to whole rand), whole numbers for counts, true/false for yes-no fields, and strings for text, including id_number and cell_phone_number. v1 accepted numeric strings such as "25000" and words such as "yes"; v2 rejects them with 422 and names the field, for example "gross_income": [{"code": "invalid_type", "message": "must be a number"}]. A body with another Content-Type returns 415.

FieldStatusv1v2
first_name, last_nameChangedRequired. Letters, spaces, hyphens, and apostrophes.Required. Letters from any alphabet, spaces, hyphens, straight or curly apostrophes and full stops, with at least one letter. Zoë, D’Angelo, Anne-Marie and C.J. are accepted.
cell_phone_numberChangedRequired. Any value, stored exactly as sent.Required. A South African mobile number (06x, 07x or 081–084) as 0821234567, 821234567, 27821234567, or +27821234567, with spaces, dashes, dots or brackets allowed (+27 82 123 4567). Stored as 0821234567.
emailChangedRequired, and must be valid.Optional. When sent, an ASCII address with a dotted domain, such as person@company.co.za. user@localhost and non-ASCII addresses are rejected.
id_numberChangedRequired. 13 digits, valid date and checksum. The 11th digit must be 0 or 1.Required. A valid South African ID number; the 11th digit can also be 2 (refugee). The applicant must be 18–100.
intentChangedRequired without intent_id. Partial title match, so Personal could match Personal Loans.Required without intent_id. Full intent title, matched case-insensitively. When both are sent they must name the same intent.
intent_idChangedRequired without intent. Must be a known intent. When both were sent, intent_id won and intent was ignored.Required without intent. Must be an active intent. When both are sent they must name the same intent; otherwise intent is reported as invalid_value ("must match intent_id").
popiChangedRequired. true, 1, "true", "yes", or "on".Required. JSON true.
partner_idsChangedOptional. Skipped all validation, with no limit.Optional. The lead is fully validated first. Up to 50 ids, as JSON integers.
partner_idRemovedOptional. Set the lead's assigned partner.Not supported. Sending a value returns 422 with partner_id as unsupported_field. Use partner_ids.
fast_applicationChangedAny value, even false, skipped the intent's extra fields on /lead and /offer.Only true skips them, and only on /offer.
gross_income, net_incomeChangedA missing one defaulted to the other, or 0. Currency symbols and separators were stripped.A missing one defaults to the other. A JSON number; decimals are rounded to whole rand.
loan_amount_required, expensesChangedCurrency symbols and separators were stripped.A JSON number; decimals are rounded to whole rand.
referee_idUnchangedAttributes the lead to another affiliate.Same.
income_sourceNewNot available.Optional. One of salary, wage, commission, self-employed, sassa, unemployed, other.
employment_typeNewNot available.Optional. One of full_time, part_time, self_employed, contract, commission, pensioner, student, homemaker, unemployed, other. Sets employed when that is left out.
credit_score_label, credit_score_intNewNot available.Optional.
api_tokenRemovedAuthenticated /lead from the body or query string.Ignored. Use the Authorization header.
Other optional fieldsChangedStored as sent.Checked against their published rules: allowed values (e.g. payment_frequency), ranges (e.g. repayment_period 1–120, payday 1–31, amounts not negative) and text lengths. See the field table in Sending a Lead.

The other optional fields are title, language, education, employed, payday, bank_name, repayment_period, employment_period, company_name, debt_amount, open_loans, payment_frequency, debt_review, street_address, suburb, city, postal_code, and province.

Intent-specific fields

In v1, POST /lead and POST /offer both enforced the extra fields an intent requires, such as income and loan amount for Personal Loans. v2 does too, on every batch row as well, and reports each missing field in the usual errors list. An intent can also narrow a field's range, for example a shorter maximum repayment_period. Each intent's rules are in its schema from GET /intent; see Intent Fields.

Placeholder values

Leave out fields you don't have, or send JSON null. If your system fills a missing value with a placeholder, v2 also accepts the placeholders below, on the fields listed below, and treats them as not provided. Use only these values:

PlaceholderExample
"" (empty string)"city": ""
null written as text"payday": "NULL"
unknown"employed": "Unknown"
n/a"bank_name": "N/A"
not specified"education": "Not specified"
undefined"province": "undefined"

Placeholders match in any letter case, and spaces before or after are ignored. Other spellings, such as none, na, -, or 0, are validated as real values, so "employed": "none" is rejected.

These fields accept placeholders:

GroupFields
Contactemail
Personaltitle, language, education
Employment and incomeemployed, income_source, employment_type, employment_period, company_name, payday, gross_income, net_income, expenses, payment_frequency
Banking and creditbank_name, loan_amount_required, repayment_period, debt_amount, open_loans, debt_review, credit_score_label, credit_score_int
Addressstreet_address, suburb, city, postal_code, province

These fields don't treat placeholders as missing:

FieldsWhat to do
first_name, last_name, cell_phone_number, id_number, popi, intent_id / intentRequired. A placeholder here is checked as a real value: it is rejected, or for names stored as the applicant's name. Send the real value.
referee_id, partner_ids, fast_applicationLeave the field out when you don't have a value.
Any other field, including intent-specific fieldsStored exactly as sent. Leave the field out when you don't have a value.

A placeholder counts as a missing value, so the rules for missing values apply: for example, if net_income is a placeholder and gross_income is sent, net_income defaults to gross_income.

The lead response

The success body drops code and adds result:

JSON
{
  "id": "k3mN9pQx",
  "status": "Lead successfully added",
  "result": "created"
}
  • Duplicates. v1 created a new lead on every call. v2 checks the applicant against your own leads: while your earlier lead has not been sent to a partner, you get it back with "result": "duplicate" and status Lead already received; once it has been sent, you get a new lead ("result": "created") that skips partners the applicant already reached. Sending the same partner_ids again returns the earlier lead and sends nothing. See Duplicate Submissions.
  • Live feedback. If your account has live feedback enabled, v1 put the outcome under a key named "0". v2 puts it under response, and omits it when you send partner_ids:
JSON
{
  "id": "k3mN9pQx",
  "status": "Lead successfully added",
  "response": { "state": "accepted", "partner": "Example Lender" }
}

5. Update offer handling

The response has matches, the lead id, and the new result (created or duplicate), with no code. Each match changes.

Before (v1):

JSON
{
  "matches": [
    {
      "id": 227,
      "company_name": "Example Lender",
      "company_logo_path": "images/partners/227/logo/logo.png",
      "company_logo_url": "https://example.com/storage/images/partners/227/logo/logo.png",
      "company_website_url": null,
      "rank": "1",
      "company_mini_logo_url": null,
      "company_wide_logo_url": null,
      "probability": 80,
      "live_score": { "url": "https://partner.example.com/apply/k3mN9pQx" },
      "end_to_end": false,
      "userable": {
        "partner_type": "api",
        "probability_conditions": "[{\"field\":\"gross_income\",\"value\":7000,\"operator\":\"greater_than\",\"results\":80}]"
      }
    },
    {
      "id": 311,
      "company_name": "Second Lender",
      "company_logo_path": null,
      "company_logo_url": null,
      "company_website_url": "https://second.example.com",
      "rank": "2",
      "company_mini_logo_url": null,
      "company_wide_logo_url": null,
      "probability": null,
      "live_score": -2,
      "end_to_end": false,
      "userable": { "partner_type": "api", "probability_conditions": null }
    }
  ],
  "id": "k3mN9pQx"
}

After (v2):

JSON
{
  "matches": [
    {
      "id": 227,
      "company_name": "Example Lender",
      "company_website_url": "",
      "company_logo_url": "https://partner.example.com/logo.png",
      "company_mini_logo_url": "",
      "company_wide_logo_url": "",
      "partner_type": "api",
      "probability_conditions": [
        { "field": "gross_income", "value": 7000, "operator": "greater_than", "results": 80 }
      ],
      "partner_slug": "example-lender",
      "rank": 1,
      "probability": 80,
      "live_scoring_api": true,
      "live_quotes": { "url": "https://partner.example.com/apply/k3mN9pQx" },
      "end_to_end": false
    }
  ],
  "id": "k3mN9pQx",
  "result": "created"
}

The second lender is not in matches because its live score was unavailable.

Fieldv1v2
rankString, for example "1"Integer, for example 1
probabilityInteger, or null when no condition matchedAlways an integer; 0 when no estimate is available
probability_conditionsInside userable, as a JSON-encoded string or nullAt the top level, as a JSON array; [] when there are none
live_scoreThe partner's score object, or -2 when unavailableNot returned. Use live_quotes.
live_quotesNot returnedThe partner's quote as JSON, when it provides one. Unavailable partners are left out of matches. Send the chosen quote back on accept.
partner_typeOnly inside userableAt the top level
userableNested partner_type and probability_conditionsNot returned. Both fields are at the top level.
partner_slugNot returnedAlways returned
live_scoring_apiNot returnedAlways returned, as a boolean
company_logo_pathRelative storage path, or nullNot returned. Use company_logo_url.
company_website_url, logo URLsnull when not set"" when not set
end_to_endBooleanUnchanged

GET /intent returns only active intents. Do not reuse an intent id from v1 or from another environment.

6. Update accept handling

FieldStatusv1v2
hashidChangedRequired. An unknown id was a 422 validation error.Required. An unknown id, or another affiliate's lead, returns 404.
partner_idChangedNot validated. A missing or unknown id caused a 500, and 0 returned a bare 200.Required integer, 1 or more, that must be a current match for the lead.
live_quotesNewIgnored.Optional. The quote the applicant chose, as JSON.
live_scoreRemovedOptional. The chosen quote, as an object.Rejected with 422. Use live_quotes.
typeUnchanged"apply-now" starts an end-to-end application.Same.
bankUnchangedOptional. Stored on the lead.Same.

A v2 accept request:

JSON
{
  "hashid": "k3mN9pQx",
  "partner_id": 227,
  "live_quotes": { "url": "https://partner.example.com/apply/k3mN9pQx" }
}

v2 also checks the lead's state. You can only accept a lead that POST /offer matched, or one whose previous accept was declined or failed. v1 had no such check.

Accept responses

Responsev1v2
Redirect{ "redirect": "…" }Unchanged
Partner details{ "id", "api", "status", "code" }; each api item had rank as a string, userable_id, and company_logo_path{ "id", "status", "api" }, plus redirect when the partner supplies one. Each api item has id, company_name, company_website_url, partner_type, live_scoring_api, end_to_end, and the logo URLs.
End-to-end, link pending{ "id", "status", "code" }{ "id", "status" }
Partner link (GET /accept/{hashid}/link){ "ready", "link" }; link is null until ready{ "ready", "link" }; link is "" until ready. Only your own leads; any other id returns 404.

See Accepting an Offer for how to handle each response.

7. Update error handling

In v1, error bodies differed by endpoint, and validation errors came back as JSON only when you sent Accept: application/json; otherwise Laravel redirected. In v2 every error is JSON with a fixed shape.

Authentication

Situationv1v2
Bad key on /lead401 {"error":"Unauthenticated."}401 {"error":"unauthorized"}
Bad key on /offer, /accept, link404 ["Cannot be authorised or authenticated."]401 {"error":"unauthorized"}
Expired key—401 {"error":"api key expired"}

Before (v1, offer and accept):

JSON
["Cannot be authorised or authenticated."]

After (v2, every endpoint):

JSON
{ "error": "unauthorized" }

Stop treating a 404 array as an authentication failure. Treat any 401 as a key problem.

Validation

Before (v1, POST /lead):

JSON
{
  "message": "The required fields for the requested Intent are missing",
  "errors": {
    "first_name": ["The first name field is required."],
    "id_number": ["The ID Number field is invalid"]
  }
}

After (v2, every endpoint):

JSON
{
  "code": "validation_failed",
  "message": "Validation failed.",
  "errors": {
    "first_name": [{ "code": "required", "message": "is required" }],
    "id_number": [{ "code": "invalid_format", "message": "must be a valid RSA ID number" }],
    "gross_income": [{ "code": "invalid_type", "message": "must be a number" }]
  }
}

The keys in errors are still your JSON field names; each holds one entry per problem. Branch on code, which never changes; the message is for people and may be reworded. The messages compare with v1 like this:

Fieldv1 messagev2 message
Any required fieldThe first name field is required.is required
first_name, last_namefirst name is not valid.must contain at least one letter, and only letters, spaces, hyphens, apostrophes or full stops
cell_phone_numberNot checkedmust be a South African mobile number (06x, 07x or 081–084)
id_numberThe ID Number field is invalidmust be a valid RSA ID number
emailThe email must be a valid email address.must be a valid email address
popiNo consent was given.Client must give consent.
intent, intent_idThe intent id field is required when intent is not present.One problem on intent: send one of: intent, intent_id
Wrong JSON typeCoerced where possiblemust be an integer, must be a number, must be a boolean, must be a string, or must be an array of integers

Missing intent fields

v1 listed each missing intent field as a validation error, on /lead and /offer. v2 does the same on /lead, /offer and every batch row: each missing field appears in errors with the code required. In the rare case an intent's settings change while a request is processed, /offer instead answers intent_schema_validation_failed with a missing_fields list.

Business rules

Situationv1v2
Unknown intent titleNot handled; usually a server error422 {"code":"intent_not_found","message":"Intent not found."}
Unknown intent_id422 validation error on intent_id422 {"code":"intent_not_found","message":"Intent not found."}
Inactive intent—422 {"code":"intent_inactive","message":"This intent is not accepting leads."}
Intent temporarily unavailable—503 {"code":"intent_unavailable","message":"This intent is temporarily unavailable."}. Retry later with backoff.
Unknown referee_id422 validation error: Invalid referee id.422 {"code":"referee_not_found","message":"The referee_id was not found."}
Accept: lead not found422 validation error: This lead does not exist.404 {"code":"lead_not_found","message":"Lead not found."}
Accept: invalid partner500 server error422 with one of the codes in Accepting an Offer
Accept: lead already sentNo check422 {"code":"lead_already_accepted","message":"This lead has already been accepted."}
Accept: another affiliate's leadAllowed404 {"code":"lead_not_found","message":"Lead not found."}
GET /lead/{hashid}: not found, or not your lead500 server error; any affiliate could read any lead404 {"code":"lead_not_found","message":"Lead not found."}

Other errors

Situationv1v2
Malformed JSON—400 {"code":"invalid_json","message":"Invalid JSON body."}
Body not sent as JSON on /lead or /offerAccepted415 {"code":"unsupported_media_type","message":"Content-Type must be application/json."}
Server error500 {"message":"Server Error"}500 {"code":"internal_error","message":"Internal error."}
Slow processing—503 {"code":"processing_timeout",…}. Retry later with backoff.
Maintenance—503 {"error":"system maintenance in progress"}, sent before the key is checked, so without a code. Retry later with backoff.

See Errors for retry guidance.

8. Verify in UAT

With your UAT key and origin:

  1. Call GET /api/v2/intent and confirm HTTP 200.
  2. Send a fictional applicant through the flow you use: POST /lead, or POST /offer then POST /accept.
  3. Send one invalid applicant and confirm your client reads the 422 field errors.
  4. Compare the v1 and v2 results for the same fictional applicant.

Keep Production traffic on v1 while you test.

9. Switch Production

When Fincheck approves your UAT run, change your Production client in one step:

  1. Replace the v1 token with your Production fce_ key.
  2. Set the base URL to https://api.engine-v2.fincheck.co.za/api/v2.
  3. Call GET https://api.engine-v2.fincheck.co.za/api/v2/intent before sending a live applicant.
  4. Record the lead id from your first live requests.

10. Stop calling v1

Once Production traffic succeeds on v2, remove every /api/v1 call and api_token from your integration.

Endpoint mapping

v1v2
POST /api/v1/leadPOST /api/v2/lead
POST /api/v1/lead/batchPOST /api/v2/lead/batch
GET /api/v1/lead/{id}GET /api/v2/lead/{hashid}
POST /api/v1/offerPOST /api/v2/offer
POST /api/v1/acceptPOST /api/v2/accept
GET /api/v1/accept/{hashid}/linkGET /api/v2/accept/{hashid}/link
GET /api/v1/intentGET /api/v2/intent
GET /api/v1/intent/{id}GET /api/v2/intent/{id}

POST /lead/batch also changed shape: v1 took a semicolon-separated CSV only. v2 accepts CSV, XLSX, XLS, or JSON, validates each row, and reports per-row results. Rows follow the same duplicate rules as POST /lead, and the partner column must be partner_ids (a partner_id column with a value is rejected). See Batch Import.

See the Changelog for the v1 retirement date once it is announced.