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.
mutation { createCase(loan: { ... }, applicants: [{ ... }], properties: [{ ... }]) { uuid } } # RESI
mutation { createBTLCase(loan: { ... }, applicants: [{ ... }], properties: [{ ... }], property_cost: { ... }, personal_details: { ... }) { uuid } } # BTL
POST
orPUT
request. -
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 } }
POST
orPUT
request, you will receive antext/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.
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.
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 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.