Introduction
The MBT API uses GraphQL to define a schema for interacting with MBT. At present the main operations are limited to creating a case, and tracking results of that case.
See the schema for details on fields however the expected flow for communicating with MBT at the time of writing:
-
Issue mutation to create or modify a case. You must retrieve
the uuid of this case. e.g.
You can issue this command via a json encoded
mutation { createCase(loan: { ... }, applicants: [{ ... }], properties: [{ ... }]) { uuid } } # RESImutation { createBTLCase(loan: { ... }, applicants: [{ ... }], properties: [{ ... }], property_cost: { ... }, personal_details: { ... }) { uuid } } # BTLPOSTorPUTrequest. -
Issue a subscription request to track the results of this case.
A sample subscription request looks like this:
You can issue this command via a json encoded
subscription { caseResults(uuid: "QQ000000001") { lender { name } amount } }POSTorPUTrequest, you will receive antext/eventstreamresponse back. These are\nseperated JSON responses as each result comes in.
Authorisation
Access to the API is protected by a token issued via the API.
This token will be provided via the authorisation header, e.g.
Authorization: Bearer <token>
Or in the case of a web socket connection via the token query param, e.g.
wss://api.mortgagebroker.tools/socket/websocket?vsn=1.0.0&token=<token>
Tokens
Tokens are valid per broker and used to assign actions to the issuing broker.
Generating Tokens
For integration from other systems (e.g. CRMs) tokens can be generated for brokers providing the token used by the integration has been granted the correct permissions. See below how to generate a company wide key.
With this key you may generate tokens impersonating your brokers / other staff. This is ideal for integration with CRM style products where the software is acting on behalf of the person. To issue a key for a person use the following mutation, providing an email in the parameters object.
mutation getToken($email: String!) {
createTokenByEmail(email: $email)
}
Please note that you must use a company api key for this request, which cannot perform any person specific actions, and it will generate a key for the person that cannot perform company wide tasks such as this.
Please contact MBT to discuss your use case to activate this functionality.
WebSocket API
The MBT WebSocket API is powered by Phoenix Sockets
and specifically Absinthe.
You can find an JS client library for Absinthe @absinthe/socket
and for Phoenix Sockets.
You may find websocat useful in developing implementations using this api.
Message Format
Messages are expected to be exhanged as JSON payloads in the form of:
{
"topic": "aStringTopic",
"event": "aStringName",
"payload": {
...
},
"ref": "refString"
}
References are expected to keep track of your replies, a reply to a message sent will contain a matching string.
Replies will be in the form of:
{
"event": "aStringName",
"payload": {
"response": {},
"status": "ok"
},
"ref": "refString",
"topic": "aStringTopic"
}
This documentation covers the version 1.0.0 of Phoenix Sockets. See the additional vsn=1.0.0 parameter.
It is equally applicable in content to version 2.0.0 but the format changes to:
[
"refStringUsedForPhxJoin",
"refString",
"aStringTopic",
"aStringName",
{ ... }
]
Note the additional reference string, this is expected to be the ref used for your initial phx_join event, and stay
consistent during your stream.
Heartbeat
To keep the websocket open and alive, Phoenix expects a heartbeat. This is a special message in the form of:
{ "topic": "phoenix", "event": "heartbeat", "payload": {}, "ref": "refString" }
Which should be sent every 30 seconds, or at least every 60. You will get / must handle a reply in the form of:
{
"event": "phx_reply",
"payload": {
"response": {},
"status":"ok"
},
"ref": "refString",
"topic":"phoenix"
}
Sending queries / subscribing to results
In order to send a GraphQL query it is necessary to setup your connection by joining the Absinthe control channel.
{
"topic": "__absinthe__:control",
"event": "phx_join",
"payload": {},
"ref": "refString"
}
{
"topic": "__absinthe__:control",
"event": "doc",
"payload": {
"query": "subscription GetResults($caseId: String!) { caseResults(uuid: $caseId) { amount, lender { name } } }",
"variables": {
"caseId": "my-case-uuid"
}
},
"ref": "refString"
}
{
"event":"phx_reply",
"payload": {
"response": {
"data":{
...
}
},
"status": "ok"
},
"ref": "refString",
"topic":"__absinthe__:control"
}
In the case of subscriptions, the reply will include
a subscriptionId, you will then receive:
{
"event":"subscription:data",
"payload": {
"result": {
"data": {
...
}
},
"subscriptionId": "subscriptionId"
},
"ref": null,
"topic": "subscriptionId"
}
When you are done with a subscription it is recommended to unsubscribe via:
{
"topic": "__absinthe__:control",
"event": "unsubscribe",
"payload": {
"subscriptionId": "subscriptionId"
},
"ref": "refString"
}
Webhook Subscriptions
Authorisation
Having created a webhook subscription, one can assign a new secret key to be used throughout the authentication process of the webhook process. Secret keys should be set by the developers using our APIs, by using a cryptographically secure pseudo-random number generators, CSPRNGs.
The requests, sent as the part of the webhook processes, should be
authorised in the API handling the request, by using the HMAC
algorithm. Two signature methods are supported, selectable per
subscription via the signatureMethod field on
createOrUpdateWebhookSubscription:
-
CANONICAL_JSON(default; the historical “legacy” method): HMAC over a canonicalised, flattened, lexicographically sorted, lowercased string built from the parsed JSON body. Kept for backward compatibility — existing integrations require no changes. -
RAW_BODY(recommended for new integrations): HMAC over the exact bytes of the request body as transmitted on the wire. Portable across JSON libraries and frameworks; the receiver verifies against the raw bytes before any JSON parsing happens.
CANONICAL_JSON (legacy)
The webhook requests contain a custom header, known as
X-Webhook-Signature.
Make sure that the custom header is not blocked nor suppressed by your reverse proxy or gateway solution, if using any.
The header contains a string value, in the form of
t={EPOCH},v1={SIGNATURE},alg={ALGORITHM}.
The authentication implementation consuming the webhook requests
should be able to parse this value to EPOCH and
SIGNATURE.
The EPOCH contains a timestamp, showing the point
of time the digestion was occurred, it is formatted as
Unix Epoch, in nanoseconds.
The SIGNATURE, on the other hand, contains the signed
value by our APIs, formatted as {EPOCH}.{DATA}.
Crucially, {DATA} is not the raw
JSON body: the body is flattened into dotted/indexed keys,
rendered as key=value pairs, sorted
lexicographically, joined with commas, and lowercased. The
receiver must perform the same canonicalisation before
recomputing the HMAC. (See the receiver samples below for
reference implementations.)
The consumer of the webhook subscriptions could use these data to
validate the incoming requests, by simply using the same
algorithm, described as ALGORITHM, to recreate the
digest.
As the matter of the fact, if request payload is not changed, the
hash value provided in the header field should be preserved.
RAW_BODY (recommended)
The RAW_BODY method splits the signature material
across two headers and signs the bytes that go on the wire,
verbatim. This is what Stripe, GitHub, Shopify and Slack do and
is what we recommend for all new integrations.
-
X-Webhook-Timestamp: Unix epoch in seconds at which the signature was generated. -
X-Webhook-Signature:v1={SIGNATURE},alg={ALGORITHM}— no embeddedt=segment.
The signed payload is
HMAC-{ALGORITHM}(secret, "{TIMESTAMP}.{RAW_BODY}"),
base64-encoded. RAW_BODY is the exact byte sequence
of the HTTP request body — do not parse and re-serialise
the JSON before verifying, or different libraries’
formatting choices (key ordering, whitespace, number
normalisation, Unicode escaping) will produce a mismatching
digest.
On the receiver side this means buffering the raw body before any JSON middleware touches it. For example:
-
Express: register
express.raw({ type: 'application/json' })before anyexpress.json()middleware, then verify againstreq.body(aBuffer) before parsing it withJSON.parse. -
FastAPI: call
raw = await request.body()first, thenrequest.json(); sign-verify againstraw. -
Go: read
r.Bodyinto a byte slice once, hand the same bytes to both the verifier andjson.Unmarshal.
Reject requests where
now - X-Webhook-Timestamp exceeds your tolerance
window (5 minutes is a sensible default) to mitigate replay
attacks, and always compare digests using a constant-time
helper such as Node’s crypto.timingSafeEqual,
Python’s hmac.compare_digest, or Go’s
crypto/hmac.Equal.
Node.js (Express, RAW_BODY)
const express = require('express');
const { createHmac, timingSafeEqual } = require('crypto');
const app = express();
const SECRET = process.env.WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 5 * 60;
// IMPORTANT: register raw BEFORE express.json() so req.body is the original Buffer.
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.headers['x-webhook-timestamp'];
const sigHeader = req.headers['x-webhook-signature'] || '';
if (!timestamp || !sigHeader.startsWith('v1=')) {
return res.status(400).send('missing signature headers');
}
if (Math.abs(Math.floor(Date.now() / 1000) - parseInt(timestamp, 10)) > TOLERANCE_SECONDS) {
return res.status(400).send('signature too old');
}
const [received] = sigHeader.slice(3).split(',');
const expected = createHmac('sha512', SECRET)
.update(`${timestamp}.${req.body.toString('utf8')}`)
.digest('base64');
const a = Buffer.from(received);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.status(401).send('bad signature');
}
const payload = JSON.parse(req.body.toString('utf8'));
console.log('verified webhook', payload.topic);
res.send('ok');
});
Python (FastAPI, RAW_BODY)
import hmac, hashlib, base64, os, time
from fastapi import FastAPI, Header, HTTPException, Request
app = FastAPI()
SECRET = os.environ["WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 5 * 60
@app.post("/webhook")
async def webhook(
request: Request,
x_webhook_timestamp: str = Header(...),
x_webhook_signature: str = Header(...),
):
raw = await request.body() # MUST be called before request.json()
if abs(int(time.time()) - int(x_webhook_timestamp)) > TOLERANCE_SECONDS:
raise HTTPException(400, "signature too old")
if not x_webhook_signature.startswith("v1="):
raise HTTPException(400, "missing v1 signature")
received = x_webhook_signature[3:].split(",", 1)[0]
expected = base64.b64encode(
hmac.new(SECRET, f"{x_webhook_timestamp}.{raw.decode()}".encode(), hashlib.sha512).digest()
).decode()
if not hmac.compare_digest(received, expected):
raise HTTPException(401, "bad signature")
return {"ok": True}
Subscription
In order to subscribe to webhook topic it is necessary to use createOrUpdateWebhookSubscription mutationmutation createWebhookSub {
createOrUpdateWebhookSubscription(
active: true,
url: "http://your.domain.com",
secret: "Check Authorisation Section"
topics: [CASE_PDF_CREATED]) {
topics
}
}
Webhook Body Format
Webhook Body is expected to be exchanged as JSON payloads in the form of:{
"topic": "topic",
"payload": {
...
}
}
Topics (payload)
CASE_PDF_CREATED{
"case_id": String,
"token": String,
"pdf": String
}
{
"additionalInformation": String,
"amount": Float,
"case": {
status: String,
uuid: String
},
"exclusionReasons": String,
"index": {
"index": Integer,
"total": Total
},
"lender": {
"btl": Boolean,
"name": String,
"reference": String,
"resi": Boolean,
"type": String,
"primaryLender": Lender
"notices": [
{
"amountAtTimeOfReport" : Integer,
"averageAmountAtTimeOfReport" : Integer,
"occurences" : Integer,
}
]
},
"screenshotPdfUrl": String,
"status": String,
"rate": Float
}
Webhook Validation Samples
Sample Payload: { case_id: "String", token: "String", pdf: "String" }
UTC Timestamp in nanosecond: "1683181188349863577"
Hash Signature: "ZAlV59AaeHPQ4UTvXZ2FBNgIqudhsACBYM555laFTf4="
Header Sent: "t=1683181188349863577,v1=ZAlV59AaeHPQ4UTvXZ2FBNgIqudhsACBYM555laFTf4=,alg=hmac"
Elixir
defmodule SignatureVerifier do
@valid_period_in_nanoseconds 60
def parse(signature) do
case String.split(signature, ",") do
[timestamp, hash, _algorithm] ->
{:ok, String.split(timestamp, "=") |> List.last(),
String.split(hash, "=") |> Enum.slice(1..-1) |> Enum.join("=")}
_ ->
{:error, "invalid signature"}
end
end
def verify(header, payload, secret) do
with {:ok, timestamp, hash} <- parse(header),
{:ok, computed_hash} <- generate_hash("#{timestamp}.#{payload}", secret) do
current_timestamp = System.system_time(:nanosecond)
signature_timestamp = String.to_integer(timestamp)
cond do
signature_timestamp + @valid_period_in_nanoseconds < current_timestamp ->
{:error, "signature is too old"}
Base.encode64(computed_hash) != hash ->
{:error, "signature is incorrect"}
true ->
:ok
end
end
end
def generate_hash(data, secret) do
{:ok, :crypto.mac(hash_alg(), sha_alg(), secret, data)}
rescue
_ -> {:error, "invalid hash"}
end
defp sha_alg(), do: :sha256
defp hash_alg, do: :hmac
end
Node.js
const express = require('express')
const app = express()
const port = 6000
app.use(express.json())
class WebhookStringPayload {
static flattenKeyValue([key, value]) {
if (typeof value === "object" && value !== null) {
if (Array.isArray(value)) {
return value.flatMap((element, index) =>
WebhookStringPayload.flattenKeyValue([`${key}`, element])
);
} else {
return Object.entries(value).flatMap(([subkey, subvalue]) =>
WebhookStringPayload.flattenKeyValue([`${key}.${subkey}`, subvalue])
);
}
} else {
return [`${key}=${WebhookStringPayload.put_value(value)}`];
}
}
static convertToString(map) {
if (typeof map === "object" && map !== null && !Array.isArray(map)) {
return Object.entries(map)
.flatMap((entry) => WebhookStringPayload.flattenKeyValue(entry))
.sort()
.join(",");
} else {
throw new Error("Input must be a non-null object.");
}
}
static put_value(value) {
if (value === null) {
return ''
}
return value
}
}
function verifySignature(data, SIGNATURE, SECRET) {
const PAYLOAD = WebhookStringPayload.convertToString(data).toLowerCase();
console.log(PAYLOAD)
const { createHmac } = require('crypto');
const VALIDITY_TIME_IN_SECONDS = 5;
function parse(signature, schema = 'v1') {
const [timestampString, hashWithVersion, algorithmString] = signature.split(',')
const timestamp = timestampString.split('=')[1];
const hash = hashWithVersion.split('=').slice(1).join('='); // in case the hash contains '='
const algorithm = algorithmString.split('=')[1];
return { timestamp, hash, algorithm };
}
function verify(header, payload, secret, schema = 'v1') {
const {
timestamp,
hash,
algorithm
} = parse(header, schema);
const integerTimestamp = parseInt(timestamp);
if ((integerTimestamp + VALIDITY_TIME_IN_SECONDS) < Date.now()) {
return false;
}
const hmac = createHmac(algorithm, secret);
hmac.update(`${timestamp}.${payload}`);
const computedHash = hmac.digest('base64');
if (computedHash === hash) {
return true;
}
return false;
}
// Example signature verification
return verify(SIGNATURE, PAYLOAD, SECRET)
}
app.use('/', (req, res) => {
if (verifySignature(req.body, req.headers['x-webhook-signature'], 'abc123')) {
console.log('ok')
res.send('ok')
}
else {
console.log('not ok')
res.send('not ok')
}
})
app.listen(port, () => {
console.log(`Example app listening on port ${port}`)
})
C#
using System;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Collections.Generic;
using System.Text.Json;
public enum Algorithm
{
sha224,
sha256,
sha384,
sha512
}
public struct Signature
{
public Signature(DateTime timestamp, long timestampNanoSeconds, byte[] hash, Algorithm algorithm) : this()
{
Timestamp = timestamp;
TimestampNanoSeconds = timestampNanoSeconds;
Hash = hash;
Algorithm = algorithm;
}
public DateTime Timestamp { get; private set; }
public long TimestampNanoSeconds { get; private set; }
public byte[] Hash { get; private set; }
public Algorithm Algorithm { get; private set; }
}
public class SignatureParser
{
public Signature Parse(string headerValue)
{
var parts = headerValue.Split(',');
if (parts.Length != 3)
{
throw new ArgumentException("Invalid signature header");
}
if (!Int64.TryParse(parts[0].Split("=")[1], out var timestamp))
{
throw new ArgumentException("Invalid timestamp in signature header");
}
var epochNow = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var hashWithVersion = parts[1];
Algorithm algorithm;
Enum.TryParse(parts[2].Split("=")[1], out algorithm);
string hashString = string.Join("=", hashWithVersion.Split("=").Skip(1));
var hash = Convert.FromBase64String(hashString);
return new Signature(epochNow, timestamp, hash, algorithm);
}
}
public class SignatureVerifier
{
private static long NanosecondsToVerify = (long)1E+10;
public bool Verify(Signature signature, string secretString, string payload)
{
var timestampAndPayload = signature.TimestampNanoSeconds + "." + payload;
var epochNow = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var epochLast = epochNow.AddTicks(NanosecondsToVerify / 100);
if (epochLast < signature.Timestamp)
{
return false;
}
var secret = Encoding.ASCII.GetBytes(secretString);
using (HMACSHA512 hmac = new HMACSHA512(secret))
{
var storedHash = new byte[hmac.HashSize / 8];
var computedHash = hmac.ComputeHash(Encoding.UTF8.GetBytes(timestampAndPayload));
for (int i = 0; i < storedHash.Length; i++)
{
if (computedHash[i] != signature.Hash[i])
{
return false;
}
}
}
return true;
}
}
public class WebhookStringPayload
{
private static IEnumerable FlattenKeyValue(KeyValuePair entry)
{
var (key, value) = entry;
if (value is Dictionary dict)
{
return dict.SelectMany(subEntry =>
FlattenKeyValue(new KeyValuePair($"{key}.{subEntry.Key}", subEntry.Value))
);
}
else if (value is List
Submitting case lazily using draft cases
One can create draft cases by using the mutations createCaseDraft and createBtlCaseDraft, for residential and buy-to-let
cases, respectively. By creating those draft cases, the consumer is
allowed to update it with subsequent mutation calls, similar to the given
APIs, updateCaseDraft and updateBtlCaseDraft.
After creating a case, the consumer is able to call the mutation
invokeCaseResults to notify the platform to process
the case.
mutation Create {
createCaseDraft(...)
}
mutation Update1 {
updateCaseDraft(...)
}
mutation Update2 {
updateCaseDraft(...)
}
...
mutation Resolution {
invokeCaseResults(...)
}
Note that the update operations will replace whole case information with the new data provided.