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.
How it works
Affordability is calculated asynchronously across multiple lenders, so the flow is submit-then-poll:
Sign the request
Build the canonical string and HMAC-sign it with your secret.
POST a job
Send the affordability payload. You get back an applicationId.
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.
Polling
You ask us. Call GET /ps/affordability/affordabilityJobs/{applicationId} on a short interval until status is DONE.
Callbacks
We tell you. Provide a callback_url and we POST each lender's result the moment it's ready, then a final summary.
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.
| Value | Sent as | Purpose |
|---|---|---|
api_client_key | In the Authorization header | Identifies your application. |
secret | Never sent — signs locally | The HMAC key. Store securely (secrets manager / env), never in a browser or mobile app. |
tid | x-tid header | Your tenant identifier. |
Request headers on every call
| Header | Example |
|---|---|
Authorization | HMAC <api_client_key>:<signature> |
X-Mk-Timestamp | 1751880000 — current Unix time, seconds |
x-tid | your-tid |
accept-version | 2.0.0 |
Content-Type | application/json |
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:
- timestamp — current Unix time in seconds, as a string, e.g.
1751880000. - body hash — lowercase hex SHA-256 of the raw request body bytes. For a request with no body (GET), hash the empty string.
- canonical string — join with newline (
\n) characters:
METHOD uppercase ({METHOD}\n{PATH}\n{timestamp}\n{body_hash}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. - signature — lowercase hex
HMAC-SHA256(secret, canonical_string). Send it asAuthorization: HMAC {api_client_key}:{signature}.
/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());
import hashlib, hmac, time, json, requests
from urllib.parse import urlsplit
API_CLIENT_KEY = "your-api-client-key"
SECRET = "your-shared-secret"
TID = "your-tid"
BASE_URL = "https://staging.mintlabs.io/ps"
def signed_headers(method, url, body=""):
parts = urlsplit(url)
path = parts.path + (("?" + parts.query) if parts.query else "") # e.g. /ps/affordability/affordabilityJobs
ts = str(int(time.time()))
body_hash = hashlib.sha256(body.encode()).hexdigest()
canonical = f"{method.upper()}\n{path}\n{ts}\n{body_hash}"
sig = hmac.new(SECRET.encode(), canonical.encode(), hashlib.sha256).hexdigest()
return {
"Authorization": f"HMAC {API_CLIENT_KEY}:{sig}",
"X-Mk-Timestamp": ts,
"x-tid": TID,
"accept-version": "2.0.0",
"Content-Type": "application/json",
}
url = f"{BASE_URL}/affordability/affordabilityJobs"
body = json.dumps({ "run_id": "3wc7", # … rest of affordability payload })
r = requests.post(url, headers=signed_headers("POST", url, body), data=body)
print(r.status_code, r.json())
API_CLIENT_KEY="your-api-client-key"
SECRET="your-shared-secret"
TID="your-tid"
BASE="https://staging.mintlabs.io"
PATH_="/ps/affordability/affordabilityJobs"
TS=$(date +%s)
BODY='{"run_id":"3wc7","...":"rest of affordability payload"}'
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $2}')
CANONICAL=$(printf '%s\n%s\n%s\n%s' "POST" "$PATH_" "$TS" "$BODY_HASH")
SIG=$(printf '%s' "$CANONICAL" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -X POST "$BASE$PATH_" \
-H "Authorization: HMAC $API_CLIENT_KEY:$SIG" \
-H "X-Mk-Timestamp: $TS" \
-H "x-tid: $TID" \
-H "accept-version: 2.0.0" \
-H "Content-Type: application/json" \
--data "$BODY"
Endpoint
Submit a job
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.
| Field | Meaning |
|---|---|
lender | Lender name — must match an entry in selected_banks. |
initial_rate / rate_type | The initial rate and whether it's Fixed or Variable. |
second_rate / second_type | Rate and type for the second period, if applicable (null if none). |
third_rate | Rate for a third period, if applicable (null if none). |
reversion_rate | Rate the deal reverts to once introductory periods end. |
percent_fee / net_fee | Product 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
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 }
]
}
| Field | Meaning |
|---|---|
status | Job-level: PENDING still running · DONE all lenders finished. |
bank_details[].status | Per lender: pass · unable_to_calculate / error. |
bank_details[].aff_amt | Maximum affordable amount for that lender (null if not calculated). |
bank_details[].screenshot_file_name | Present 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.
{
"application_id": "2b1e9c7a-…-uuid",
"bank_name": "Santander",
"status": "pass",
"aff_amt": 452000,
"banks_completed": 3,
"banks_total": 10,
"screenshot_url": "https://…/screenshot/2b1e…/Santander"
}
| Field | Meaning |
|---|---|
bank_name | The lender this update is for. |
status | pass · unable_to_calculate / error. |
aff_amt | Maximum affordable amount for this lender (null if not calculated). |
banks_completed / banks_total | Progress so far vs. total expected — drive a progress bar with these. |
screenshot_url | Link 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
| Detail | Behaviour |
|---|---|
| Method | HTTP POST, Content-Type: application/json. |
| Per-lender | One POST per lender, as each completes — use for lazy loading. |
| Completion | A single POST carrying "event": "job_complete". |
| Ordering | Lenders finish independently, so messages can arrive in any order — track by bank_name. |
| Your endpoint | Must be a publicly reachable HTTPS URL. Respond 2xx promptly (within ~5s); we do not follow redirects. |
| Fallback | The GET result endpoint stays available — poll it if a callback is missed. |
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_detailsentry includesscreenshot_file_namewhen a screenshot exists (nullif not). - Callbacks — the per-lender and
job_completemessages include a ready-to-usescreenshot_urlthat points at the download endpoint below.
Download one lender's screenshot
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
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 }
]
}
/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.
DONEis terminal. Individual lenders may still beunable_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.
| Status | Code / message | Cause & fix |
|---|---|---|
| 401 | invalid_signature | Signature didn't match. Recheck the canonical string (method, exact path+query, body hash) and secret. |
| 401 | timestamp_out_of_range | Your clock is > 5 min off ours. Sync via NTP and send a current timestamp. |
| 401 | replay_detected | This exact signed request was already used. Sign every request fresh — never reuse a signature. |
| 401 | invalid_api_client | The api_client_key isn't recognised for this tid. Check both values. |
| 400 | Request header tid is missing | Send your tenant in the x-tid header on every request. |
| 404 | Missing run id | run_id is required on Submit a job and was omitted. |
| 409 | A job is already running… | A job with that application_id is in progress. Poll it, or use a new id. |
| 404 | Record not found | No job for that applicationId under your tenant. |
Reference
Integration checklist
- Store
api_client_key,secret, andtidserver-side — never expose the secret in a browser or mobile client. - Sign the exact path you request, including the
/ps/affordabilityprefix 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_idon submit — it's required; the request is rejected without it. - Submit → capture
applicationId→ get results by either polling untilDONEor receiving callbacks (your choice).applicationId, notrun_id, is the key used to fetch results and screenshots. - Handle
401codes distinctly from business errors (409/404).