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:

  1. Issue mutation to create or modify a case. You must retrieve the uuid of this case. e.g.
    mutation { createCase(loan: { ... }, applicants: [{ ... }], properties: [{ ... }]) { uuid } } # RESI mutation { createBTLCase(loan: { ... }, applicants: [{ ... }], properties: [{ ... }], property_cost: { ... }, personal_details: { ... }) { uuid } } # BTL
    You can issue this command via a json encoded POST or PUT request.
  2. Issue a subscription request to track the results of this case. A sample subscription request looks like this:
    subscription { caseResults(uuid: "QQ000000001") { lender { name } amount } }
    You can issue this command via a json encoded POST or PUT request, you will receive an text/eventstream response back. These are \n seperated 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.

Go to Account Settings, scroll to bottom, enter integration name, hit generate, copy resulting 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" }
You may then issue queries (including subscriptions).
{ "topic": "__absinthe__:control", "event": "doc", "payload": { "query": "subscription GetResults($caseId: String!) { caseResults(uuid: $caseId) { amount, lender { name } } }", "variables": { "caseId": "my-case-uuid" } }, "ref": "refString" }
You will receive a reply in the form of:
{ "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. 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}. The order of the fields found in JSON should be preserved in order to create a consistent hash value.

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.

Subscription

In order to subscribe to webhook topic it is necessary to use createOrUpdateWebhookSubscription mutation
mutation 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 }
CASE_RESULTS
{ "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 list) { return list.SelectMany((element, index) => FlattenKeyValue(new KeyValuePair($"{key}[{index}]", element)) ); } else if (value is List stringList) { return stringList.SelectMany((element, index) => FlattenKeyValue(new KeyValuePair($"{key}[{index}]", element)) ); } else { return new[] { $"{key}={PutValue(value)}" }; } } public static string ConvertToString(Dictionary map) { if (map is object && !map.GetType().IsArray) { return string.Join(",", map.SelectMany(entry => FlattenKeyValue(new KeyValuePair(entry.Key, entry.Value))) .OrderBy(s => s) ); } else { throw new ArgumentException("Input must be a non-null object."); } } private static string PutValue(object value) { if (value == null) { return string.Empty; } return value.ToString(); } } public class Program { public static void Main() { var jsonData = new Dictionary { { "payload", new Dictionary { {"index", new Dictionary {{"index", 1}, {"total", 1}}}, {"status", "successful"}, { "case", new Dictionary { {"status", "active"}, {"uuid", "caf1f302-cb85-4e01-be47-024996c151a4"} } }, { "lender", new Dictionary { {"name", "Lender 5"}, {"type", "first_charge"}, {"reference", null}, {"resi", false}, {"btl", false}, {"notices", new List()}, {"primaryLender", null} } }, {"amount", 76948}, {"additionalInformation", ""}, {"amountMax", 76949}, {"exclusionReasons", new List()}, {"screenshotPdfUrl", "integration/126/aldermore/tmp/export_ade.pdf"} } }, {"topic", "case_results"} }; var data = WebhookStringPayload.ConvertToString(jsonData).ToLower(); var verifier = new SignatureVerifier(); var parser = new SignatureParser(); var sig = parser.Parse("t=1703775357892348958,v1=KtpYkmT0hb4adgJgi5QOhVHwuYzBTglKC9iRSe4iQOA=,alg=hmac"); bool isValid = verifier.Verify(sig, "abc123", data); if (isValid) { Console.WriteLine("Signature is valid!"); } else { Console.WriteLine("Signature is invalid."); } } }

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.