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 is length-checked and 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.
  • Deduplication. Sending the same applicant again inside the deduplication window returns the existing lead instead of creating a new one.
  • 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.
  • Status-only error codes. The HTTP status is the code. Bodies carry a message, not a code.
  • 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.
  • code is no longer returned in response bodies.
  • 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. Each key comes with the API origin it belongs to. See Environments.

2. Change the base path

Replace /api/v1 with /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: whole numbers for money and 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": ["must be an integer"]. A body with another Content-Type returns 415.

FieldStatusv1v2
first_name, last_nameChangedRequired. Letters, spaces, hyphens, and apostrophes. UnConfirmed rejected.Required. Also allows periods, for initials such as C.J.. UnConfirmed rejected.
cell_phone_numberChangedRequired. Any value, stored exactly as sent.Required. 9–13 characters: 0821234567, 821234567, 27821234567, or +27821234567. Stored as 0821234567.
emailChangedRequired, and must be valid.Optional. Must be valid when sent.
id_numberChangedRequired. 13 digits, valid date and checksum. The 11th digit must be 0 or 1.Required. Same checks, but the 11th digit can also be 2 (refugee).
intentChangedRequired without intent_id. Partial title match, so Personal could match Personal Loans.Required without intent_id. Full intent title, matched case-insensitively.
intent_idUnchangedRequired without intent. Must be a known intent.Required without intent. Must be an active intent.
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.Rejected with 422. 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. Whole rand, as a JSON integer.
loan_amount_required, expensesChangedCurrency symbols and separators were stripped.Whole rand, as a JSON integer.
referee_idUnchangedAttributes the lead to another affiliate.Same.
income_sourceNewNot available.Optional.
credit_score_label, credit_score_intNewNot available.Optional.
api_tokenRemovedAuthenticated /lead from the body or query string.Ignored. Use the Authorization header.
Other optional fieldsUnchangedStored as sent.Stored as sent.

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. In v2 only POST /offer enforces them, and it reports what is missing in one list; see error handling. POST /lead accepts the lead without them, but partners have less to match on, so send what you have.

Placeholder values

Leave out fields you don't have, or send JSON null. Placeholder strings such as unknown, n/a, or "null" are validated like any other value, so "email": "unknown" is rejected with a 422.

The lead response

The success body drops code:

JSON
{
  "id": "k3mN9pQx",
  "status": "Lead successfully added"
}
  • Duplicates. v1 created a new lead on every call. v2 returns the existing lead's id with HTTP 200 when the same applicant is sent again inside the deduplication window. Treat both as success.
  • 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 still has two top-level keys, matches and the lead id, 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"
}

The second lender is missing from the v2 response 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 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
{
  "message": "Validation failed.",
  "errors": {
    "first_name": ["required"],
    "id_number": ["must be a valid RSA ID number"],
    "gross_income": ["must be an integer"]
  }
}

The keys in errors are still your JSON field names, but the messages are shorter and stable, so you can match on them:

Fieldv1 messagev2 message
Any required fieldThe first name field is required.required
first_name, last_namefirst name is not valid.invalid
id_numberThe ID Number field is invalidrequired, must be numeric, must be 13 digits, or must 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.required when intent is not present / required when intent_id is not present
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 checks them only on /offer and returns them together:

JSON
{
  "message": "intent schema validation failed",
  "missing_fields": ["gross_income", "loan_amount_required"]
}

Business rules

Situationv1v2
Unknown intent titleNot handled; usually a server error422 {"message":"intent not found"}
Unknown intent_id422 validation error on intent_id422 {"message":"intent not found"}
Inactive intent—422 {"message":"intent is not accepting leads"}
Unknown referee_id422 validation error: Invalid referee id.422 {"message":"referee_id not found"}
Accept: lead not found422 validation error: This lead does not exist.404 {"message":"lead not found"}
Accept: invalid partner500 server error422 with one of the messages in Accepting an Offer
Accept: lead already sentNo check422 {"message":"lead is not in matched, declined, or failed state"}
GET /lead/{hashid}: not found, or not your lead500 server error; any affiliate could read any lead404 {"message":"lead not found"}

Other errors

Situationv1v2
Malformed JSON—400 {"message":"Invalid JSON body"}
Body not sent as JSON on /lead or /offerAccepted415 {"message":"Content-Type must be application/json"}
Server error500 {"message":"Server Error"}500 {"message":"internal error"}
Slow processing or maintenance—503 with a message or error. 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. Point the client at the Production origin that came with the key.
  3. Call GET /api/v2/intent against Production 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. See Batch Import.

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