Affordability API v2.0.0 · staging
Data Dictionary →

This page is one of two companion documents — Integration Guide and Data Dictionary. The link above only works if both HTML files are kept together in the same folder (e.g. both attachments saved to the same directory before opening). If you only have this file, ask whoever sent it for affordability-api-data-dictionary.html.

Integration Guide

Affordability API

Submit a mortgage affordability job, then retrieve the per-lender results. Every request is authenticated with an HMAC-SHA256 signature — no session, no login, just your key and secret.

Base URL https://staging.mintlabs.io/ps Auth HMAC-SHA256 Format application/json

How it works

Affordability is calculated asynchronously across multiple lenders, so the flow is submit-then-poll:

01 · SIGN

Sign the request

Build the canonical string and HMAC-sign it with your secret.

02 · SUBMIT

POST a job

Send the affordability payload. You get back an applicationId.

03 · GET RESULTS

Poll or receive callbacks

Retrieve results whichever way suits you — pull them by polling, or have us push them to you.

Two ways to get results — pick whichever fits

Both return the same data. Choose the model that suits your integration; you can even use callbacks with polling as a safety net.

Option A · Pull

Polling

You ask us. Call GET /ps/affordability/affordabilityJobs/{applicationId} on a short interval until status is DONE.

Best for: the simplest server-to-server integration — no public endpoint to host.
Get result & polling →
Option B · Push

Callbacks

We tell you. Provide a callback_url and we POST each lender's result the moment it's ready, then a final summary.

Best for: receiving results in real time as each lender completes. Needs a public HTTPS endpoint. How you use each update in your UI is entirely up to you.
Callbacks & lazy loading →

Authentication

Credentials & headers

We issue you three values. The secret is never sent over the wire — it only ever signs requests, so keep it on your server side.

ValueSent asPurpose
api_client_keyIn the Authorization headerIdentifies your application.
secretNever sent — signs locallyThe HMAC key. Store securely (secrets manager / env), never in a browser or mobile app.
tidx-tid headerYour tenant identifier.

Request headers on every call

HeaderExample
AuthorizationHMAC <api_client_key>:<signature>
X-Mk-Timestamp1751880000 — current Unix time, seconds
x-tidyour-tid
accept-version2.0.0
Content-Typeapplication/json
Timestamps are single-use Sign every request fresh. The signature is bound to the timestamp, and a signed request is accepted only once, within a ±5-minute window of our server clock. Re-sending a previous signature is rejected as replay_detected, and a clock more than 5 minutes off returns timestamp_out_of_range. Keep your server clock in sync (NTP).

Authentication

Signing a request

The signature covers the method, path, timestamp, and a hash of the body — so any tampering invalidates it. Build it in four steps:

  1. timestamp — current Unix time in seconds, as a string, e.g. 1751880000.
  2. body hash — lowercase hex SHA-256 of the raw request body bytes. For a request with no body (GET), hash the empty string.
  3. canonical string — join with newline (\n) characters:
    {METHOD}\n{PATH}\n{timestamp}\n{body_hash}
    METHOD uppercase (POST). PATH is the request path including any query string, exactly as sent — everything after the host, e.g. /ps/affordability/affordabilityJobs. No host, no scheme.
  4. signature — lowercase hex HMAC-SHA256(secret, canonical_string). Send it as Authorization: HMAC {api_client_key}:{signature}.
Sign the exact path you call The path in the canonical string must match the request path our server receives, byte for byte — including the /ps/affordability prefix and any query string. A mismatch here is the most common cause of invalid_signature.

Authentication

Code examples

A reusable signer, then a signed POST. Swap in your credentials.

const crypto = require('crypto');

const API_CLIENT_KEY = 'your-api-client-key';
const SECRET         = 'your-shared-secret';
const TID            = 'your-tid';
const BASE_URL       = 'https://staging.mintlabs.io/ps';

// Build the signed headers. `url` is the full request URL — the signed path is
// derived from it, so it always matches the path you actually call.
function signedHeaders(method, url, body = '') {
  const u         = new URL(url);
  const path      = u.pathname + u.search;  // e.g. /ps/affordability/affordabilityJobs
  const timestamp = String(Math.floor(Date.now() / 1000));
  const bodyHash  = crypto.createHash('sha256').update(body).digest('hex');
  const canonical = `${method.toUpperCase()}\n${path}\n${timestamp}\n${bodyHash}`;
  const signature = crypto.createHmac('sha256', SECRET).update(canonical).digest('hex');
  return {
    'Authorization':  `HMAC ${API_CLIENT_KEY}:${signature}`,
    'X-Mk-Timestamp': timestamp,
    'x-tid':          TID,
    'accept-version': '2.0.0',
    'Content-Type':   'application/json',
  };
}

// Submit an affordability job.
const url  = `${BASE_URL}/affordability/affordabilityJobs`;
const body = JSON.stringify({ run_id: '3wc7', /* … rest of affordability payload */ });

const res = await fetch(url, {
  method: 'POST', headers: signedHeaders('POST', url, body), body,
});
console.log(res.status, await res.json());

Endpoint

Submit a job

POST/ps/affordability/affordabilityJobs

Submits an affordability calculation. Responds immediately with an applicationId; the calculation runs in the background.

Request body

The affordability payload (applicants, property, income, selected lenders). Your onboarding pack includes the full schema and a worked example. Optionally include callback_url if you have push delivery enabled.

{
  "run_id": "3wc7",   // required — your identifier for this affordability run
  "application_id": "OR99ZDDNEYIDG571L5Z8J5J1LK",   // optional; we generate one if omitted
  "callback_url": "https://your-app.example.com/hooks/affordability",  // optional; enables real-time callbacks
  "data": {
    "no_of_applicants": 2,
    "purchase_property_value": 400000,
    "purchase_term": 300
    // … full applicant & property details
  },
  "lender_metadata": [   // optional; per-lender rate/fee overrides
    { "lender": "HSBC",      "initial_rate": 4.39, "rate_type": "Fixed", "second_rate": 7.25, "second_type": "Variable", "third_rate": null, "reversion_rate": 7.25, "percent_fee": null, "net_fee": 3999 },
    { "lender": "Barclays",  "initial_rate": 4.74, "rate_type": "Fixed", "second_rate": 8.24, "second_type": "Variable", "third_rate": null, "reversion_rate": 8.24, "percent_fee": null, "net_fee": 964 }
    // … one entry per lender in selected_banks
  ],
  "selected_banks": ["HSBC", "Barclays", "Santander"]
}

run_id is required — a request without it is rejected with 404 Missing run id. application_id stays optional; omit it and we generate one for you.

Lender metadata (optional)

lender_metadata lets you pass known rate/fee terms per lender, one object per entry in selected_banks, matched by the lender field.

FieldMeaning
lenderLender name — must match an entry in selected_banks.
initial_rate / rate_typeThe initial rate and whether it's Fixed or Variable.
second_rate / second_typeRate and type for the second period, if applicable (null if none).
third_rateRate for a third period, if applicable (null if none).
reversion_rateRate the deal reverts to once introductory periods end.
percent_fee / net_feeProduct fee, expressed as a percentage or a flat amount (whichever applies; the other is null).

Response · 201 Created

{
  "message": "Job accepted for (2b1e9c7a-…-uuid)",
  "applicationId": "2b1e9c7a-…-uuid",
  "runId": "3wc7"
}

Store the applicationId — it's the key used to fetch results and screenshots (run_id is echoed back but is not used to look anything up). Submitting a duplicate application_id while a job is still running returns 409 Conflict.

Endpoint

Get result

GET/ps/affordability/affordabilityJobs/{applicationId}

Returns the current state of a job. This request has no body — sign it with the empty-string body hash.

While calculating · 200 OK

{
  "application_id": "2b1e9c7a-…-uuid",
  "status": "PENDING",
  "bank_details": []
}

When complete · 200 OK

{
  "application_id": "2b1e9c7a-…-uuid",
  "status": "DONE",
  "bank_details": [
    { "bank_name": "Santander", "status": "pass", "aff_amt": 452000, "screenshot_file_name": "santander_2b1e.png" },
    { "bank_name": "Barclays",  "status": "pass", "aff_amt": 438500, "screenshot_file_name": "barclays_2b1e.png" },
    { "bank_name": "HSBC",      "status": "unable_to_calculate", "aff_amt": null, "screenshot_file_name": null }
  ]
}
FieldMeaning
statusJob-level: PENDING still running · DONE all lenders finished.
bank_details[].statusPer lender: pass · unable_to_calculate / error.
bank_details[].aff_amtMaximum affordable amount for that lender (null if not calculated).
bank_details[].screenshot_file_namePresent when a screenshot exists (null otherwise). Download the image via the screenshot endpoints.

An unknown or not-yet-created applicationId returns 404 Not Found.

Real-time delivery

Callbacks

Option B for getting results. Instead of polling, provide a callback_url when you submit and we POST results to your endpoint as they happen — one message per lender the moment it finishes, then a final summary once every lender is done. Because each lender arrives individually, you can render results progressively (lazy loading) rather than waiting for the whole job — but that's optional, and how you use the updates is entirely your choice.

Enable it

Include callback_url in the submit body (see Submit a job). We then send two kinds of message to that URL.

Message 1 · per-lender update

Sent each time a single lender finishes — this is the one you use to lazy-load results into your UI.

POST{your callback_url}
{
  "application_id":  "2b1e9c7a-…-uuid",
  "bank_name":       "Santander",
  "status":          "pass",
  "aff_amt":         452000,
  "banks_completed": 3,
  "banks_total":     10,
  "screenshot_url":  "https://…/screenshot/2b1e…/Santander"
}
FieldMeaning
bank_nameThe lender this update is for.
statuspass · unable_to_calculate / error.
aff_amtMaximum affordable amount for this lender (null if not calculated).
banks_completed / banks_totalProgress so far vs. total expected — drive a progress bar with these.
screenshot_urlLink to the lender's result screenshot (null if unavailable).

Message 2 · job complete

Sent once, after every lender has finished. Identify it by "event": "job_complete".

{
  "application_id":        "2b1e9c7a-…-uuid",
  "event":                 "job_complete",
  "total_banks":           10,
  "passed":                7,
  "failed":                3,
  "all_screenshots": [
    { "bank_name": "Santander", "status": "pass", "aff_amt": 452000, "screenshot_url": "https://…" },
    { "bank_name": "HSBC",      "status": "unable_to_calculate", "aff_amt": null, "screenshot_url": null }
  ],
  "screenshots_index_url": "https://…/screenshots/2b1e…"
}

Delivery & your endpoint

DetailBehaviour
MethodHTTP POST, Content-Type: application/json.
Per-lenderOne POST per lender, as each completes — use for lazy loading.
CompletionA single POST carrying "event": "job_complete".
OrderingLenders finish independently, so messages can arrive in any order — track by bank_name.
Your endpointMust be a publicly reachable HTTPS URL. Respond 2xx promptly (within ~5s); we do not follow redirects.
FallbackThe GET result endpoint stays available — poll it if a callback is missed.
Securing your endpoint Use a hard-to-guess HTTPS callback URL (e.g. one containing a random token) and treat the callback as a trigger to fetch/confirm via GET result when you need certainty. Signed callbacks can be enabled on request — ask your integration manager.

Results

Screenshots

Each lender result can include a screenshot (a visual capture of the affordability calculation). Screenshots are available with both polling and callbacks, and the images are retrieved from dedicated endpoints using the same HMAC signing as every other request.

Where the reference appears

  • Polling — each bank_details entry includes screenshot_file_name when a screenshot exists (null if not).
  • Callbacks — the per-lender and job_complete messages include a ready-to-use screenshot_url that points at the download endpoint below.

Download one lender's screenshot

GET/ps/affordability/screenshot/{applicationId}/{bankName}

Streams the PNG image for a single lender (Content-Type: image/png). bankName must be URL-encoded. Returns 404 with a JSON error if the job, lender, or screenshot isn't found (not_found, bank_not_found, screenshot_unavailable).

List all screenshots for a job

GET/ps/affordability/screenshots/{applicationId}

Returns a JSON index of every lender's screenshot download URL for the job.

{
  "application_id":       "2b1e9c7a-…-uuid",
  "job_status":           "DONE",
  "total_banks":          3,
  "screenshots_index_url": "https://…/ps/affordability/screenshots/2b1e…",
  "screenshots": [
    { "bank_name": "Santander", "status": "pass", "aff_amt": 452000, "screenshot_url": "https://…/ps/affordability/screenshot/2b1e…/Santander" },
    { "bank_name": "HSBC",      "status": "unable_to_calculate", "aff_amt": null, "screenshot_url": null }
  ]
}
Same auth as every call These endpoints are HMAC-signed like the rest of the API. Sign the exact path you request — e.g. /ps/affordability/screenshot/{applicationId}/{bankName} — with the empty-string body hash (they are GETs), and send the usual Authorization, X-Mk-Timestamp, and x-tid headers. If you received a screenshot_url in a callback, sign that URL's path when you fetch it.

Guide

Polling for completion

After submitting, poll GET /ps/affordability/affordabilityJobs/{applicationId} until status is DONE.

  • Poll every 3–5 seconds — most jobs finish within a minute, depending on lender count.
  • Apply a sensible overall timeout (e.g. a few minutes) and surface a retry to the user rather than polling forever.
  • Each poll is a new signed request — generate a fresh timestamp and signature every time.
  • DONE is terminal. Individual lenders may still be unable_to_calculate; that is a result, not an error.

Reference

Errors

Authentication failures return 401 with a JSON body { "error": "<code>" }. Other failures return a plain-text message with the status below.

StatusCode / messageCause & fix
401invalid_signatureSignature didn't match. Recheck the canonical string (method, exact path+query, body hash) and secret.
401timestamp_out_of_rangeYour clock is > 5 min off ours. Sync via NTP and send a current timestamp.
401replay_detectedThis exact signed request was already used. Sign every request fresh — never reuse a signature.
401invalid_api_clientThe api_client_key isn't recognised for this tid. Check both values.
400Request header tid is missingSend your tenant in the x-tid header on every request.
404Missing run idrun_id is required on Submit a job and was omitted.
409A job is already running…A job with that application_id is in progress. Poll it, or use a new id.
404Record not foundNo job for that applicationId under your tenant.

Reference

Integration checklist

  • Store api_client_key, secret, and tid server-side — never expose the secret in a browser or mobile client.
  • Sign the exact path you request, including the /ps/affordability prefix and any query string.
  • Hash the raw body bytes you actually send; use the empty-string hash for GET.
  • Generate a fresh Unix-seconds timestamp and signature for every request.
  • Keep your server clock synced (NTP) to stay inside the ±5-minute window.
  • Always send run_id on submit — it's required; the request is rejected without it.
  • Submit → capture applicationId → get results by either polling until DONE or receiving callbacks (your choice). applicationId, not run_id, is the key used to fetch results and screenshots.
  • Handle 401 codes distinctly from business errors (409/404).
Support Questions, production credentials, or the full request schema — contact your Mintlabs integration manager. This guide covers the staging environment; production base URL and keys are issued at go-live.