MortgageBrokerTools API
The MBT API is GraphQL-first. All case creation, result retrieval, user management,
and webhook configuration are performed through a single /graphql endpoint — there are no REST alternatives.
Key advantages of GraphQL for this integration:
- Client-driven queries — request exactly the fields you need; no over- or under-fetching.
- Self-documenting schema — use GraphiQL or the schema browser to explore types, fields, and relationships interactively.
- Non-breaking additions — new fields are added without affecting existing queries. Fields are deprecated before removal.
- Single-request fetches — retrieve a case, its results, and lender details in one call.
The three main integration paths are:
- Residential (RESI) —
createCaseorcreateCaseDraft→invokeCaseResults - Buy-to-let (BTL) —
createBtlCaseorcreateBtlCaseDraft→invokeCaseResults - Webhook push — receive result and PDF events via
createOrUpdateWebhookSubscription
Release Notes
Deployments follow the branch pipeline: main (nightly) → staging → release (production).
The GraphQL schema is additive-only — new fields and types are introduced without breaking existing queries.
Fields are deprecated before removal; your existing queries will continue to work unchanged.
30 June 2026
New GraphQL features
-
Paginated
casesquery withlimit/offsetargs (max 40), and a newsearchquery returningPaginatedCases { totalCount, cases }. Searches across case UUID, client name, and reference. -
New
lendersquery withactive,limit,offset, andtype(RESI/BTL) args. Replaces ad-hoc filtering onactiveLenders, which is retained for backward compatibility. -
New
magicLinkmutation returning{ url, expiresAt }. Action enum values:CRITERIA,RESI,BTL,SOURCING,VULNERABLE. -
Casetype gains:adviser,applicant(newApplicantShorttype),office,createdAt,lastUpdatedAt,caseType. -
Lendertype gains:btlName,imageResi,imageBtl,referral,address,contact,disclosure. New objects:LenderReferral,LenderAddress,LenderContact,Disclosure. -
CaseResultgainslenderProducts: [LenderProduct!]!with new objectLenderProduct(id,code,mortgageClass,initialRateYear,initialRate,bestBuy) and enumMortgageClass(Fixed,Variable,Tracker,Discount). -
Credit checks: new
CreditCheck/CreditCheckDraftinput objects (consentGivenAt: DateTime!,residenceHistory: [ResidenceHistory!]!) and acreditCheckfield on all applicant input and response types. Three years of residence history required. -
Foreign national support:
foreignNational,foreignStatus,foreignNationalResidence, andforeignNationalResidenceYearsRemainingadded to RESI and BTL personal details. New enumsForeignStatusandForeignNationalResidence. Loan.ownershipenum gainsRIGHT_TO_BUYandFIRST_HOMES_SCHEME.Loan.reasonenum gainsLET_TO_BUY.LoangainspurchaseDiscount; BTL Loan gainsmultiblockUnits.IncomegainsbonusYearThree(annual bonus, year 3).- Expenditure enums gain
EV_SALARY_SACRIFICEfor both RESI and BTL. AdditionalIncome.income_typegainsRENT_ROOM_SCHEME.PersonalDetailsgainsrentTenant(monthly rent paid if applicant is currently a tenant).CaseResult.amount/amountMaxsemantics clarified:amountis the lend amount;amountMaxis the maximum based on product and income.
Webhook changes
-
New
signatureMethodarg oncreateOrUpdateWebhookSubscription. EnumWebhookSignatureMethod:CANONICAL_JSON(legacy, default) andRAW_BODY(recommended). Existing subscriptions remain onCANONICAL_JSONuntil explicitly migrated. -
RAW_BODYmode signs the exact wire bytes. Headers:X-Webhook-Timestamp(Unix seconds) andX-Webhook-Signature: v1=<base64-hmac>,alg=sha512. Enforce a 5-minute freshness window. -
CANONICAL_JSON(legacy) is unchanged:X-Webhook-Signature: t=<nanoseconds>,v1=<base64-hmac>,alg=sha512. -
New topic
CRITERIA_PDF_CREATED— fires when a criteria PDF is generated. Payload:{ topic, payload: { case_id, token, uuid, pdf, reference } }. When not associated with a case, onlypdfandreferenceare present. -
Note:
case_pdf_createdpayload does not carryreference— that field is emitted only oncriteria_pdf_created. - Subscription response shape gains the
signatureMethodfield.
Environments
| Environment | Web | API endpoint | GraphiQL |
|---|---|---|---|
| Staging | mortgagebroker.tools | https://api.mortgagebroker.tools/graphql |
api.mortgagebroker.tools/graphiql |
| Production | mortgagebrokertools.co.uk | https://api.mortgagebrokertools.co.uk/graphql |
api.mortgagebrokertools.co.uk/graphiql |
Staging tokens can be generated from /api_tokens. Production credentials are provisioned by MBT — allow 2 weeks lead time before go-live.
GraphiQL
GraphiQL is an in-browser IDE for exploring and testing the API. It provides syntax highlighting, autocompletion, inline schema documentation, and a live response viewer. Use it to prototype queries before integrating them into your application.
Authentication
Every request to the GraphQL API must include a valid JWT in the Authorization header:
Authorization: Bearer <your-jwt-token>
For WebSocket connections, pass the token as a query parameter:
wss://api.mortgagebroker.tools/socket/websocket?vsn=1.0.0&token=<your-jwt-token>
Tokens are available from /api_tokens or generated programmatically via the token mutations listed in the Mutations section.
Example curl request:
curl -X POST https://api.mortgagebroker.tools/graphql \
-H "Authorization: Bearer <your-jwt-token>" \
-H "Content-Type: application/json" \
-d '{"query": "{ activeLenders { name resi btl } }"}'
Token Types
Three JWT types exist, ordered from lowest to highest privilege. Each is scoped to a specific trust boundary.
| Broker | Company | Integration | |
|---|---|---|---|
| Privilege | Lowest | Mid | Highest |
| Scope | One broker account | One company and its brokers | Platform-wide |
| Can do | Create, update, and run cases; act as that broker | Issue broker JWTs; create broker accounts; manage brokers within the company | Create companies; bootstrap accounts; issue company-level credentials |
| Cannot do | Cross-broker operations; create other brokers; obtain tokens for others | Cross-company operations; create other companies | Day-to-day broker operations (unless explicitly designed to) |
| Mental model | "I am Broker X" | "I manage Company A's brokers" | "I onboard companies onto MBT" |
1. Broker Token
Used by broker users, backend services, or CRM integrations acting on behalf of a specific adviser. Scoped entirely to that one broker — it cannot impersonate other brokers or access other accounts. This is the token used for creating and managing cases.
2. Company Token
Used by internal company systems, admin dashboards, or trusted backend services acting on behalf of the company. Can act on behalf of any broker within the company, issue broker JWTs, and create new broker accounts. Cannot operate outside its own company boundary or create other companies.
To generate a broker token using a company token:
mutation {
createTokenForBroker(email: "adviser@example.com")
}
3. Integration Token
Used by authorised third parties and platform-level integrations. Represents MBT's trust in an external integrator. Can create companies, bootstrap accounts, and issue company-level credentials. Extremely sensitive — IP-restricted, audited, and tightly rate-limited. Contact MBT to request an integration token.
Quick Start
<token>Need a different token? Generate one at /api_tokens
GraphiQL — Interactive Explorer
GraphiQL is the in-browser IDE for the API. It provides syntax highlighting, autocompletion, inline schema docs, and a live response viewer. The ▶ Try in GraphiQL buttons throughout this section open GraphiQL with the example query and variables pre-loaded.
GraphiQL benefits:
- Clients specify exactly the fields they want — one query returns a perfectly tailored response.
- The schema defines types, fields, and relationships — the Docs explorer makes it self-discoverable.
- Additions to the API never break existing clients.
- A single query can fetch all related entities in one request.
Stage 1 — Create a Draft Case
Use createCaseDraft to create a case in draft status — the case can be updated with
further calls to updateCaseDraft before invoking affordability.
Use createCase if all data is available upfront and you want to trigger affordability immediately.
Mutation:
mutation Create_Case_Using_Draft(
$applicants: [ApplicantDraft!]!,
$loan: LoanDraft!,
$properties: [Property!]!,
$preference: Preference
) {
createCaseDraft(
applicants: $applicants,
loan: $loan,
properties: $properties,
preference: $preference
) {
uuid
status
magicLink
}
}
Variables:
{
"applicants": [
{
"title": "MR",
"firstName": "Bob",
"lastName": "Peters",
"additionalIncome": [
{ "amount": 2000, "type": "DIVIDEND_INCOME" }
],
"companyDirectorIncome": {
"dividends": [10000, 12000],
"lengthOfService": { "months": 0, "years": 30 },
"netProfitBeforeDividends": [70000, 65000],
"operatingProfit": [90000, 95000],
"percentageOfShareHolding": 30,
"renumeration": [50000, 45000]
},
"expenditure": [
{ "amount": 130, "type": "BUY_NOW_PAY_LATER" },
{ "amount": 20, "type": "REPAID_BUY_NOW_PAY_LATER" },
{ "amount": 3000, "type": "BALANCE_BUY_NOW_PAY_LATER" },
{ "amount": 80, "type": "COUNCIL_TAX" },
{ "amount": 30, "type": "BUILDING_INSURANCE" }
],
"income": {
"amount": 10000,
"bonusByMonth": [400, 200, 200],
"bonusByQuarter": [1000, 1200, 1200],
"bonusByYear": [9000],
"carAllowance": 4000,
"commissionByMonth": [120, 130, 110],
"commissionByYear": [1000, 1000, 1000],
"contractType": "PERMANENT",
"extraAllowance": 200,
"flightPay": [0, 0, 0],
"locationAllowance": 2000,
"overtime": [50, 130, 20]
},
"personalDetails": {
"adults": 2,
"barclayPremierCustomer": false,
"countryOfResidence": "ENGLAND",
"dateOfBirth": "2000-01-01",
"dependants": 1,
"employmentStatus": "EMPLOYED",
"maritalStatus": "SINGLE",
"nationality": "UK",
"residentialStatus": "OWNER",
"retirementAge": 70,
"retirementIncome": 30000
},
"propertyIncome": [6000],
"selfEmployedIncome": {
"income": [100000, 10000, 10000],
"term": { "months": 0, "years": 5 }
}
},
{
"title": "MRS",
"firstName": "Nicola",
"lastName": "Ata",
"additionalIncome": [
{ "amount": 2000, "type": "DIVIDEND_INCOME" }
],
"expenditure": [],
"income": {
"amount": 90000,
"carAllowance": 5000,
"contractType": "PERMANENT"
},
"personalDetails": {
"adults": 2,
"barclayPremierCustomer": false,
"countryOfResidence": "ENGLAND",
"dateOfBirth": "2000-01-01",
"employmentStatus": "EMPLOYED",
"maritalStatus": "SINGLE",
"nationality": "UK",
"residentialStatus": "OTHER",
"retirementAge": 70,
"retirementIncome": 9000
},
"propertyIncome": [600],
"selfEmployedIncome": {
"income": [10000, 10000, 10000],
"term": { "months": 0, "years": 5 }
}
}
],
"loan": {
"borrowingAmount": 600000,
"existingBankProvider": "DANSKE",
"existingMortgageAmount": null,
"helpToBuyEquityAmount": 0,
"interestOnlyAmount": 0,
"isForDebtConsolidation": false,
"isForNewBuildProperty": false,
"leaseholdTerm": 900,
"mainResidence": true,
"mortgageTerms": ["TWO_YEARS", "FIVE_YEARS"],
"mortgageTypes": ["FIXED"],
"ownership": "STANDARD",
"postCode": "E1 8PY",
"propertyStyle": "CONVERTED_FLAT_OR_MAISONETTE",
"propertyType": "FREEHOLD",
"purchaseAmount": 900000,
"reason": "HOME_MOVER",
"region": "GREATER_LONDON",
"term": { "months": 0, "years": 20 }
},
"preference": {
"expectChangePersonalCircumstances": true,
"expectCriticalIllnessCover": true,
"expectHaveSignificantSavings": true,
"expectIncomeToDecrease": true,
"expectLifeAssurance": true,
"expectRegularExpenditureToIncrease": true,
"freeLenderFees": true,
"plansLeaveEmployment": true
},
"properties": [
{
"agencyFees": 100,
"allowanceForRentalVoids": 100,
"councilTax": 100,
"foreignIncome": true,
"grossRent": 100,
"groundRentServiceCharge": 100,
"maintenance": 100,
"mortgage": {
"interestBalance": 1000,
"joint": false,
"monthlyPayments": [50],
"mortgageType": "PART_AND_PART",
"rate": 10,
"repaymentBalance": 5000,
"term": { "months": 0, "years": 30 },
"value": 10000
},
"otherMonthlyCosts": 100,
"usage": "TO_BE_LET",
"utilities": 100
}
]
}
Expected response:
{
"data": {
"createCaseDraft": {
"magicLink": "https://gw.mortgagebroker.tools/rails/magic/...",
"status": "OPEN",
"uuid": "<your-case-uuid>"
}
}
}
The magicLink is a one-time, short-lived SSO link. It redirects the user into the MBT
web app without requiring a separate sign-in. A fresh link can be requested at any time using the
magicLink mutation.
Update a draft (optional)
Mutation:
mutation UpdateDraft($uuid: String!, $loan: LoanDraft) {
updateCaseDraft(uuid: $uuid, loan: $loan) {
uuid
status
}
}
Variables:
{
"uuid": "<your-case-uuid>",
"loan": {
"borrowingAmount": 650000
}
}
Invoke affordability on a draft
Mutation:
mutation InvokeCase($uuid: String!) {
invokeCaseResults(caseUuid: $uuid) {
uuid
status
pdfLink
magicLink
}
}
Variables:
{"uuid": "<your-case-uuid>"}
Stage 2 — Retrieve Results
Poll caseResults every 5–10 seconds. Use index.total to know how many
results to expect. After 90 seconds call completeCase to finalise and trigger PDF
generation, then call caseResults one final time for the complete set.
MBT automatically completes cases after 5 minutes.
Query:
query CaseResults($uuid: String!) {
caseResults(uuid: $uuid) {
lender {
reference
name
}
index {
index
total
}
amount
amountMax
status
additionalInformation
screenshotPdfUrl
rate
}
}
Variables:
{"uuid": "<your-case-uuid>"}
Example response:
{
"data": {
"caseResults": [
{
"lender": { "name": "Kent Reliance", "reference": "kentreliance" },
"index": { "index": 6, "total": 54 },
"amount": 1284696, "amountMax": 1284696,
"status": "SUCCESSFUL", "additionalInformation": null, "rate": 4.85
},
{
"lender": { "name": "Cambridge Building Society", "reference": "cambridge" },
"index": { "index": 22, "total": 54 },
"amount": 794838, "amountMax": 794838,
"status": "SUCCESSFUL", "additionalInformation": null
}
]
}
}
Mutation:
mutation CompleteCase($uuid: String!) {
completeCase(caseUuid: $uuid) {
uuid
status
pdfLink
magicLink
}
}
Variables:
{"uuid": "<your-case-uuid>"}
Optional — Create an Adviser and Issue Their Token
Requires a company token. The email must be unique within MBT.
Using a customer reference as the prefix (e.g. 12345@yourcompany.com) is recommended.
Mutation:
mutation {
createUser(user: {
email: "12345@yourcompany.com",
name: "Bob Broker",
roles: BROKER
}) {
uuid
}
}
Variables:
{}Expected response:
{
"data": {
"createUser": {
"uuid": "ed70ebc7-cf78-4d00-8aef-5a5d0c1d430b"
}
}
}
Mutation:
mutation {
createTokenForBroker(email: "12345@yourcompany.com")
}
Variables:
{}Expected response:
{
"data": {
"createTokenForBroker": "<broker-jwt>"
}
}
Buy-to-Let (BTL)
The BTL flow mirrors RESI using createBtlCase / createBtlCaseDraft:
Mutation:
mutation CreateBtlCase(
$loan: BtlLoan!,
$propertyCost: BtlPropertyCost!,
$personalDetails: BtlPersonalDetails!,
$applicants: [BtlApplicant!]!,
$properties: [BtlProperty!]!
) {
createBtlCase(
loan: $loan,
propertyCost: $propertyCost,
personalDetails: $personalDetails,
applicants: $applicants,
properties: $properties
) {
uuid
status
magicLink
}
}
Variables:
{
"loan": {
"borrowingAmount": 200000,
"mortgageTerms": ["TWO_YEARS"],
"mortgageTypes": ["FIXED"],
"ownership": "STANDARD",
"postCode": "SW1A 1AA",
"purchaseAmount": 300000,
"reason": "PURCHASE",
"term": { "months": 0, "years": 25 }
},
"propertyCost": {
"annualRentalIncome": 18000
},
"personalDetails": {
"portfolioLandlord": false
},
"applicants": [
{
"personalDetails": {
"dateOfBirth": "1980-01-01",
"employmentStatus": "EMPLOYED",
"nationality": "UK",
"residentialStatus": "OWNER",
"retirementAge": 67
},
"income": {
"amount": 75000,
"contractType": "PERMANENT"
}
}
],
"properties": []
}
Use the GraphiQL explorer or schema browser to discover the full field set for each input type.
Queries
All queries require a valid JWT. See the schema browser for full type details.
activeLenders
Returns the list of lenders that are currently active in this environment.
Query:
query {
activeLenders {
name
reference
resi
btl
}
}
Variables:
{}lenders
Returns lenders with optional filtering by segment and active state.
| Argument | Type | Default | Description |
|---|---|---|---|
active | Boolean | — | Filter to active or inactive lenders only. |
limit | Int | 20 | Maximum number to return. |
offset | Int | 0 | Number to skip. |
type | LenderSegmentType | — | RESI or BTL. Omit for all. |
Query:
query Lenders($active: Boolean, $limit: Int, $offset: Int) {
lenders(active: $active, limit: $limit, offset: $offset) {
address {
street
town
postcode
county
country
}
btl
btlName
contact {
website
phoneNumber
faxNumber
}
imageBtl
imageResi
name
notices {
firstDetectedAt
level
percentageChange
percentageOfOccurences
scenarioReference
}
primaryLender {
name
}
reference
referral {
btlLink
resiLink
rioLink
secondChargeLink
}
resi
type
}
}
Variables:
{"active": true, "limit": 10, "offset": 0}
case
Returns the case (RESI or BTL) for the given UUID, scoped to the current token.
| Argument | Type | Required |
|---|---|---|
uuid | String | Yes |
Query:
query GetCase($uuid: String!) {
case(uuid: $uuid) {
uuid
status
caseType
createdAt
lastUpdatedAt
magicLink
}
}
Variables:
{"uuid": "<your-case-uuid>"}
caseResidential
Returns a residential case with full type-specific fields including loan, applicants, and credit check data.
Query:
query GetResiCase($uuid: String!) {
caseResidential(uuid: $uuid) {
uuid
status
loan { borrowingAmount reason }
applicants { firstName lastName }
}
}
Variables:
{"uuid": "<your-case-uuid>"}
caseBtl
Returns a buy-to-let case with full type-specific fields.
Query:
query GetBtlCase($uuid: String!) {
caseBtl(uuid: $uuid) {
uuid
status
loan { borrowingAmount }
}
}
Variables:
{"uuid": "<your-case-uuid>"}
caseResults
Returns the current set of affordability results for a case. Use for polling (every 5–10 s).
index.total gives the total number of results expected.
amount is the lend amount; amountMax is the maximum based on product and income.
| Argument | Type | Required |
|---|---|---|
uuid | String | Yes — the case UUID |
Query:
query CaseResults($uuid: String!) {
caseResults(uuid: $uuid) {
lender { name reference resi btl }
index { index total }
amount
amountMax
status
additionalInformation
screenshotPdfUrl
rate
lenderProducts {
code
mortgageClass
initialRate
initialRateYear
bestBuy
}
}
}
Variables:
{"uuid": "<your-case-uuid>"}
cases
Returns a paginated list of cases for the authenticated user.
| Argument | Type | Default | Max |
|---|---|---|---|
limit | Int | 20 | 40 |
offset | Int | 0 | — |
Query:
query Cases($limit: Int, $offset: Int) {
cases(limit: $limit, offset: $offset) {
uuid
status
caseType
createdAt
}
}
Variables:
{"limit": 20, "offset": 0}
search
Searches cases by UUID, client name, and reference. Returns paginated results with a total count.
| Argument | Type | Required | Default |
|---|---|---|---|
query | String | Yes | — |
limit | Int | No | 20 (max 40) |
offset | Int | No | 0 |
Query:
query Search($q: String!, $limit: Int, $offset: Int) {
search(query: $q, limit: $limit, offset: $offset) {
totalCount
cases { uuid status createdAt }
}
}
Variables:
{"q": "Smith", "limit": 20, "offset": 0}
currentCompany
Returns the company for the authenticated token. Requires a broker or company token.
Query:
query {
currentCompany {
name
fcaNumber
}
}
Variables:
{}caseStatistics
Returns aggregated statistics for a given case type and value.
| Argument | Type | Required |
|---|---|---|
type | CaseStatisticType | Yes |
value | String | Yes |
Query:
query CaseStatistics($type: CaseStatisticType!, $value: String!) {
caseStatistics(type: $type, value: $value) {
type
value
count
}
}
Variables:
{"type": "LENDER", "value": "halifax"}
occupations
Returns ONS-compliant occupation entries for use in applicant personal details.
| Argument | Type | Description |
|---|---|---|
group | String | Filter by ONS group name. |
code | Int | Filter by ONS code. |
name | String | Filter by occupation name. |
first | Int (default 20) | Limit results. |
offset | Int (default 0) | Skip results. |
Query:
query Occupations($name: String, $first: Int) {
occupations(name: $name, first: $first) {
code
name
group
}
}
Variables:
{"name": "engineer", "first": 10}
Mutations
Token Management
createTokenForBroker (requires company token)
Creates a broker JWT by email address or UUID. The preferred mutation for generating adviser tokens.
Mutation:
mutation CreateTokenForBroker($email: String!) {
createTokenForBroker(email: $email)
}
Variables:
{"email": "adviser@example.com"}
createTokenByEmail (requires company token)
Creates a broker token by email address.
Mutation:
mutation CreateTokenByEmail($email: String!) {
createTokenByEmail(email: $email)
}
Variables:
{"email": "adviser@example.com"}
createTokenByUuid (requires company token)
Creates a broker token by user UUID.
Mutation:
mutation CreateTokenByUuid($uuid: String!) {
createTokenByUuid(uuid: $uuid)
}
Variables:
{"uuid": "<user-uuid>"}
createTokenForCompany (requires integration token)
Creates a company token. Identify the company by any one of the arguments below.
| Argument | Type | Description |
|---|---|---|
fca_number | String | The company's FCA number. |
company_number | String | Companies House number. |
domain | String | Domain excluding scheme (e.g. example.co.uk). |
email | String | Email of an active member of the company. |
uuid | String | Company UUID. |
Mutation:
mutation CreateTokenForCompany($fcaNumber: String!) {
createTokenForCompany(fcaNumber: $fcaNumber)
}
Variables:
{"fcaNumber": "<company-fca-number>"}
User & Company Provisioning
createUser (requires company token)
Creates a user under the current company. The email must be unique within MBT.
Mutation:
mutation {
createUser(user: {
email: "12345@yourcompany.com",
name: "Bob Broker",
roles: BROKER
}) {
uuid
}
}
Variables:
{}createCompany (requires integration token)
Creates a company with an admin user. Optionally sends an onboarding email.
| Argument | Type | Required |
|---|---|---|
company | CompanyInput | Yes |
admin_user | AdminUserInput | Yes |
send_onboarding_email | Boolean | No |
Mutation:
mutation CreateCompany($company: CompanyInput!, $adminUser: AdminUserInput!, $sendOnboardingEmail: Boolean) {
createCompany(
company: $company,
adminUser: $adminUser,
sendOnboardingEmail: $sendOnboardingEmail
) {
uuid
name
}
}
Variables:
{
"company": { "name": "Example Firm", "fcaNumber": "123456" },
"adminUser": { "email": "admin@example.com", "name": "Admin User" },
"sendOnboardingEmail": false
}
Magic Link
magicLink
Generates a one-time, short-lived SSO link. The link redirects the user into the MBT web app without requiring a separate sign-in. Available actions:
| Action | Description |
|---|---|
RESI | Residential case view |
BTL | Buy-to-let case view |
CRITERIA | Criteria lookup |
SOURCING | Sourcing |
VULNERABLE | Vulnerable customer flow |
Mutation:
mutation MagicLink($action: MagicLinkAction!) {
magicLink(action: $action) {
url
expiresAt
}
}
Variables:
{"action": "RESI"}
Residential Cases
createCase
Creates a residential case and immediately invokes affordability. Preferred when all data is available upfront. Charged per invocation.
| Argument | Type | Required |
|---|---|---|
loan | Loan | Yes |
applicants | [Applicant!]! | Yes |
properties | [Property!]! | Yes |
preference | Preference | No |
Mutation:
mutation ($applicants: [Applicant!]!, $loan: Loan!, $preference: Preference, $properties: [Property!]!) {
createCase(applicants: $applicants, loan: $loan, preference: $preference, properties: $properties) {
magicLink
status
uuid
}
}
Variables:
{
"applicants": [
{
"additionalIncome": [
{ "amount": 10, "type": "ANNUITIES" },
{ "amount": 10, "type": "BURSARY" },
{ "amount": 10, "type": "CHILD_SUPPORT_INCOME" }
],
"companyDirectorIncome": {
"dividends": [2000, 3000],
"lengthOfService": { "months": 0, "years": 30 },
"netProfitBeforeDividends": [4000, 5000],
"operatingProfit": [6000, 7000],
"percentageOfShareHolding": 25,
"renumeration": [500, 600]
},
"email": "johnappleseed@apple.com",
"expenditure": [
{ "amount": 100, "type": "SECURED_LOANS" },
{ "amount": 100, "type": "BALANCE_SECURED_LOANS" }
],
"firstName": "Ersin",
"homePhone": "07469123456",
"income": {
"occupation": { "code": 2421, "name": "Financial controller (qualified)" },
"amount": 80000,
"bonusByMonth": [100, 200, 300],
"bonusByQuarter": [100, 200, 300, 400],
"bonusByYear": [100, 200, 300],
"carAllowance": 0,
"commissionByMonth": [100, 200, 300],
"commissionByYear": [100, 200, 300],
"contractType": "PERMANENT",
"extraAllowance": 0,
"flightPay": [100, 200, 300],
"locationAllowance": 0,
"netMonthlyIncome": 0,
"nursingBank": [100, 200, 300],
"overtime": [100, 200, 300],
"timeInEmployment": "MORE_THAN_12_MONTHS"
},
"lastName": "Test Mortgage Types",
"mobile": "07469123456",
"notes": "Example notes",
"personalDetails": {
"adults": 1,
"barclayPremierCustomer": false,
"dateOfBirth": "1984-01-01",
"dependants": 1,
"employmentStatus": "EMPLOYED",
"gender": "MALE",
"maritalStatus": "SINGLE",
"nationality": "UK",
"residentialStatus": "OWNER",
"retirementAge": 68,
"retirementIncome": 4000
},
"propertyIncome": [1000, 2000, 3000],
"reference": "the unique reference",
"selfEmployedIncome": {
"income": [2000, 3000, 4000],
"term": { "months": 0, "years": 3 }
},
"title": "Mr."
}
],
"loan": {
"borrowingAmount": 100000,
"existingBankProvider": "HALIFAX",
"existingMortgageAmount": 100000,
"existingMortgageLender": "HALIFAX",
"helpToBuyEquityAmount": 0,
"interestOnlyAmount": 0,
"isForDebtConsolidation": false,
"isForNewBuildProperty": false,
"leaseholdTerm": 100,
"mainResidence": true,
"mortgageTerm": "TWO_YEARS",
"mortgageTerms": ["TWO_YEARS", "TEN_YEARS"],
"mortgageType": "FIXED",
"mortgageTypes": ["FIXED", "TRACKER", "DISCOUNT"],
"ownership": "STANDARD",
"ownershipProprietor": false,
"postCode": "TN6 1AS",
"propertyStyle": "TERRACED_HOUSE",
"propertyType": "FREEHOLD",
"purchaseAmount": 250000,
"reason": "REMORTGAGE",
"remortgageType": "LIKE_FOR_LIKE",
"term": { "months": 6, "years": 5 }
},
"preference": {
"expectChangePersonalCircumstances": false,
"expectCriticalIllnessCover": false,
"expectHaveSignificantSavings": false,
"expectIncomeToDecrease": false,
"expectLifeAssurance": false,
"expectRegularExpenditureToIncrease": false,
"freeLenderFees": false,
"plansLeaveEmployment": false
},
"properties": [
{
"agencyFees": 100,
"allowanceForRentalVoids": 100,
"buyToLet": false,
"councilTax": 100,
"foreignIncome": true,
"grossRent": 100,
"groundRentServiceCharge": 100,
"hmo": false,
"maintenance": 100,
"mortgage": {
"interestBalance": 1000,
"joint": false,
"monthlyPayments": [50],
"mortgageType": "INTEREST_ONLY",
"rate": 10,
"repaymentBalance": 5000,
"term": { "months": 0, "years": 30 },
"value": 10000
},
"otherMonthlyCosts": 100,
"usage": "ALREADY_LET",
"utilities": 100
}
]
}
createCaseDraft
Creates a draft residential case. All fields optional — build the case incrementally,
then call invokeCaseResults when ready. Not charged at creation.
See the Quick Start section for a full 2-applicant payload.
Mutation:
mutation CreateCaseDraft(
$applicants: [ApplicantDraft!]!,
$loan: LoanDraft!,
$properties: [Property!]!
) {
createCaseDraft(
applicants: $applicants,
loan: $loan,
properties: $properties
) {
uuid
status
magicLink
}
}
Variables:
{
"applicants": [
{
"firstName": "Bob",
"lastName": "Smith",
"personalDetails": {
"dateOfBirth": "1985-06-15",
"employmentStatus": "EMPLOYED"
},
"income": {
"amount": 60000,
"contractType": "PERMANENT"
}
}
],
"loan": {
"borrowingAmount": 250000,
"purchaseAmount": 350000,
"reason": "PURCHASE",
"term": { "years": 25, "months": 0 }
},
"properties": []
}
updateCaseDraft
Updates a draft residential case. Each update replaces the supplied section entirely.
Mutation:
mutation UpdateCaseDraft($uuid: String!, $loan: LoanDraft) {
updateCaseDraft(uuid: $uuid, loan: $loan) {
uuid
status
}
}
Variables:
{"uuid": "<your-case-uuid>", "loan": {"borrowingAmount": 300000}}
Buy-to-Let Cases
createBtlCase
Creates a BTL case and immediately invokes affordability. Charged per invocation.
| Argument | Type | Required |
|---|---|---|
loan | BtlLoan | Yes |
property_cost | BtlPropertyCost | Yes |
personal_details | BtlPersonalDetails | Yes |
applicants | [BtlApplicant!]! | Yes |
properties | [BtlProperty!]! | Yes |
createBtlCaseDraft
Creates a draft BTL case. All fields optional. Not charged at creation.
updateBtlCaseDraft
Updates a draft BTL case. Takes the same arguments as createBtlCaseDraft plus uuid.
Case Lifecycle
invokeCaseResults
Triggers affordability evaluation on a draft case. After calling this, poll
caseResults for incoming lender results. Charged per invocation.
mutation InvokeResults($caseUuid: String!) {
invokeCaseResults(caseUuid: $caseUuid) {
uuid
status
}
}
completeCase
Marks the case as complete and triggers PDF generation. Call once you have sufficient results
(recommended: after 90 seconds). MBT automatically completes cases after 5 minutes.
After completing, call caseResults once more for the final comprehensive result set.
mutation CompleteCase($caseUuid: String!) {
completeCase(caseUuid: $caseUuid) {
uuid
status
}
}
cloneCase
Clones a case (RESI or BTL). Optionally creates a new client record and depersonalises the data.
| Argument | Type | Required |
|---|---|---|
case_uuid | String | Yes |
new_client | Boolean | Yes |
depersonalize | Boolean | Yes |
advisor_uuid | String | No |
Webhook Management
createOrUpdateWebhookSubscription (requires company token)
Creates or updates the company's webhook subscription. A company has at most one subscription record; calling this again updates the existing one.
| Argument | Type | Required | Description |
|---|---|---|---|
url | String | Yes | HTTPS endpoint to receive events. |
topics | [WebhookTopic!]! | Yes | CASE_RESULTS, CASE_PDF_CREATED, CRITERIA_PDF_CREATED. |
secret | String | No | Shared secret for HMAC signing. Generate with a CSPRNG. |
active | Boolean | No | Default true. |
signature_method | WebhookSignatureMethod | No | RAW_BODY (recommended) or CANONICAL_JSON (legacy default). |
Mutation:
mutation {
createOrUpdateWebhookSubscription(
active: true,
secret: "your-secret",
topics: CASE_RESULTS,
url: "https://your.endpoint.example.com/webhook"
) {
active
id
secret
topics
url
}
}
Variables:
{}Subscriptions (WebSocket)
The caseResults subscription streams results in real time as lenders respond.
Subscriptions require a WebSocket connection using
Phoenix Sockets and
Absinthe.
Client libraries: @absinthe/socket,
Phoenix.js.
For CLI testing, websocat is useful.
1. Connect
wss://api.mortgagebroker.tools/socket/websocket?vsn=1.0.0&token=<YOUR_JWT>
2. Join the control channel
{ "topic": "__absinthe__:control", "event": "phx_join", "payload": {}, "ref": "1" }
3. Subscribe to results
{
"topic": "__absinthe__:control",
"event": "doc",
"payload": {
"query": "subscription GetResults($uuid: String!) { caseResults(uuid: $uuid) { amount lender { name } index { index total } } }",
"variables": { "uuid": "<case-uuid>" }
},
"ref": "2"
}
The reply includes a subscriptionId. Events then arrive as:
{
"event": "subscription:data",
"payload": {
"result": { "data": { "caseResults": { ... } } },
"subscriptionId": "<subscriptionId>"
},
"ref": null,
"topic": "<subscriptionId>"
}
4. Unsubscribe
{
"topic": "__absinthe__:control",
"event": "unsubscribe",
"payload": { "subscriptionId": "<subscriptionId>" },
"ref": "3"
}
Try the subscription in GraphiQL
GraphiQL supports subscriptions over WebSocket. Replace the UUID with a real case UUID, then click ▶ Try in GraphiQL.
Subscription:
subscription {
caseResults(uuid: "<your-case-uuid>") {
additionalInformation
amount
case {
magicLink
pdfLink
status
uuid
}
exclusionReasons
index {
index
total
}
lender {
btl
reference
resi
}
screenshotPdfUrl
status
}
}
Variables:
{}Heartbeat
Send every 30 seconds (must be at least every 60) to keep the connection alive:
{ "topic": "phoenix", "event": "heartbeat", "payload": {}, "ref": "hb1" }
Protocol versions
vsn=1.0.0: messages are JSON objects with topic, event, payload, ref.
vsn=2.0.0: messages are arrays [joinRef, ref, topic, event, payload], where joinRef matches the ref from the initial phx_join and stays consistent for the stream.
Webhooks
MBT pushes event notifications to your endpoint when cases produce results or PDFs are generated.
Delivery is best-effort: one attempt per event; succeeded on any 2xx response; failures are logged
but not retried. Always call completeCase and then caseResults as your
definitive source of truth — do not rely solely on webhook delivery.
For local development, use ngrok to expose a local endpoint. A test harness with example receiver code is available at github.com/Parobus/mbt-webhooks-example.
Topics
| Topic | When it fires |
|---|---|
CASE_RESULTS | As each lender result arrives during affordability calculation. |
CASE_PDF_CREATED | When the case PDF is ready (generated after completeCase, approx. 5 minutes after invocation). |
CRITERIA_PDF_CREATED | When a criteria PDF is generated. |
CASE_RESULTS payload
{
"topic": "case_results",
"payload": {
"additionalInformation": "...",
"amount": 250000,
"amountMax": 275000,
"case": { "status": "completed", "uuid": "..." },
"exclusionReasons": [],
"index": { "index": 3, "total": 42 },
"lender": {
"btl": false, "resi": true,
"name": "Example Bank", "reference": "EX",
"primaryLender": { ... }
},
"screenshotPdfUrl": "https://...",
"status": "ok",
"rate": 4.85
}
}
Screenshot URLs in results are valid for 1 hour. Download and store them on receipt for compliance purposes.
CASE_PDF_CREATED payload
{
"topic": "case_pdf_created",
"payload": {
"case_id": 123,
"token": "...",
"uuid": "...",
"pdf": "https://...presigned-url..."
}
}
PDF URLs are valid for 24 hours. Store the file on receipt.
CRITERIA_PDF_CREATED payload
{
"topic": "criteria_pdf_created",
"payload": {
"case_id": 123,
"token": "...",
"uuid": "...",
"pdf": "https://...presigned-url...",
"reference": "..."
}
}
When not associated with a case, only pdf and reference are present.
Signature Verification
Every delivery is signed with HMAC-SHA-512 using the subscription's shared secret. Choose your signing method when creating the subscription:
| RAW_BODY (recommended for new integrations) | CANONICAL_JSON (legacy — existing integrations only) | |
|---|---|---|
| What is signed | Raw HTTP body bytes, as transmitted on the wire | Flattened, sorted, lowercased JSON string |
| Timestamp unit | Seconds (in X-Webhook-Timestamp header) | Nanoseconds (embedded in X-Webhook-Signature as t=) |
| Signature header | v1=<base64>,alg=sha512 (no t=) | t=<ns>,v1=<base64>,alg=sha512 |
| JSON library sensitivity | None | High — both sides must agree on canonical form |
RAW_BODY (recommended)
Headers on each delivery:
X-Webhook-Timestamp: 1715600000
X-Webhook-Signature: v1=<base64-hmac>,alg=sha512
The signed payload is "<timestamp>" + "." + <raw-body-bytes>.
Do not parse and re-serialise the JSON before verifying — any whitespace or key-ordering
difference will produce a mismatching digest. Read the raw request body before any JSON middleware touches it.
Framework notes:
- Express:
express.raw({ type: 'application/json' })beforeexpress.json(); usereq.body(a Buffer). - FastAPI:
raw = await request.body()beforerequest.json(). - Go: read
r.Bodyinto a byte slice once, pass the same bytes to both verifier andjson.Unmarshal. - AWS Lambda: use
event.bodystring beforeJSON.parse; base64-decode first ifisBase64Encodedis true.
Always compare digests using a constant-time function (crypto.timingSafeEqual, hmac.compare_digest, crypto/hmac.Equal) and reject requests where the timestamp is more than 5 minutes old.
Node.js (RAW_BODY)
const { createHmac, timingSafeEqual } = require('crypto');
const SECRET = process.env.WEBHOOK_SECRET;
// Register express.raw BEFORE express.json
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 headers');
if (Math.abs(Math.floor(Date.now() / 1000) - parseInt(timestamp, 10)) > 300)
return res.status(400).send('stale');
const received = sigHeader.slice(3).split(',')[0];
const expected = createHmac('sha512', SECRET)
.update(`${timestamp}.${req.body.toString('utf8')}`)
.digest('base64');
const a = Buffer.from(received), 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'));
res.send('ok');
});
Python / FastAPI (RAW_BODY)
import hmac, hashlib, base64, os, time
from fastapi import FastAPI, Header, HTTPException, Request
SECRET = os.environ["WEBHOOK_SECRET"].encode()
@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)) > 300:
raise HTTPException(400, "stale")
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")
CANONICAL_JSON (legacy)
Header on each delivery:
X-Webhook-Signature: t=<nanoseconds>,v1=<base64-hmac>,alg=sha512
t is Unix time in nanoseconds (~19 digits, not seconds).
The signed payload is "<t>" + "." + canonical_string(body), where
canonical_string:
- JSON-decodes the body into a map.
- Recursively flattens to dotted/indexed
key=valuepairs (nested maps:a.b=1; lists:xs[0]=10). - Sorts pairs lexicographically (byte/ordinal order — not locale-aware).
- Joins with
,. - Lowercases the entire resulting string.
Gotchas: lowercase after sorting; integers must not gain a trailing .0; use a parser that preserves int vs. float for large IDs.
Elixir (CANONICAL_JSON)
defmodule SignatureVerifier do
@valid_period_ns 300_000_000_000 # 5 minutes in nanoseconds
def verify(header, canonical_payload, secret) do
with {:ok, timestamp, hash} <- parse(header),
{:ok, computed} <- generate_hash("#{timestamp}.#{canonical_payload}", secret) do
cond do
String.to_integer(timestamp) + @valid_period_ns < System.system_time(:nanosecond) ->
{:error, "signature is too old"}
Base.encode64(computed) != hash ->
{:error, "signature is incorrect"}
true ->
:ok
end
end
end
defp parse(signature) do
case String.split(signature, ",") do
[t, h, _alg] ->
{:ok,
String.split(t, "=") |> List.last(),
String.split(h, "=") |> Enum.slice(1..-1) |> Enum.join("=")}
_ -> {:error, "invalid signature"}
end
end
defp generate_hash(data, secret),
do: {:ok, :crypto.mac(:hmac, :sha512, secret, data)}
end
Node.js (CANONICAL_JSON)
class WebhookStringPayload {
static flattenKeyValue([key, value]) {
if (typeof value === "object" && value !== null) {
if (Array.isArray(value)) {
return value.flatMap((el, i) =>
WebhookStringPayload.flattenKeyValue([`${key}[${i}]`, el]));
} else {
return Object.entries(value).flatMap(([sk, sv]) =>
WebhookStringPayload.flattenKeyValue([`${key}.${sk}`, sv]));
}
}
return [`${key}=${value ?? ''}`];
}
static convertToString(map) {
return Object.entries(map)
.flatMap(e => WebhookStringPayload.flattenKeyValue(e))
.sort()
.join(',')
.toLowerCase();
}
}
function verifySignature(rawBody, sigHeader, secret) {
const [tPart, hashPart, algPart] = sigHeader.split(',');
const timestamp = tPart.split('=')[1];
const hash = hashPart.split('=').slice(1).join('=');
const alg = algPart.split('=')[1];
const body = JSON.parse(rawBody);
const canonical = WebhookStringPayload.convertToString(body);
const { createHmac, timingSafeEqual } = require('crypto');
const expected = createHmac(alg, secret).update(`${timestamp}.${canonical}`).digest('base64');
const a = Buffer.from(hash), b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}
Rate Limits & Billing
Rate limits
| Request group | Limit |
|---|---|
Affordability (createCase / invokeCaseResults) | 180 requests / minute |
Result streaming (WebSocket / caseResults) | 120 active sessions / minute |
| All other requests | 600 requests / minute |
Billing
API charging is based on case execution. You are only billed when you invoke affordability
via createCase, createBtlCase, or invokeCaseResults.
All subsequent queries against a completed case (results, lender data, case status) are free.
WAF / IP allowlisting
If your server-to-server requests are blocked by CloudFront WAF (the SignalKnownBotDataCenter managed rule blocks requests originating from known data-centre IP ranges), contact MBT to have your IP range allowlisted via the contact form.
FAQ
Can I call createCase directly instead of createCaseDraft + invokeCaseResults?
Yes. If you don't need to build the case incrementally or store a draft, use createCase
(RESI) or createBtlCase (BTL). This creates the case and immediately triggers affordability
in a single mutation call.
How do you manage schema changes? Is there semantic versioning?
GraphQL is client-driven — you define the output in your query, so new fields added to the schema are non-breaking for existing queries. We never silently remove or change the type of existing fields. On the rare occasion we need to change a type, we deprecate the old field and introduce a new alternative, retaining the original for backward compatibility.
What is the best practice for retrieving affordability results?
Three options, in order of integration complexity:
-
Poll
caseResultsevery 5–10 seconds. Useindex.totalto know how many results to expect. Most integrations use this approach. -
WebSocket subscription — subscribe to
caseResultsfor real-time streaming as results arrive. -
Webhook push — subscribe to
CASE_RESULTSto receive push notifications to your endpoint.
Regardless of approach: allow 90 seconds for lenders to respond, then call completeCase
to finalise. MBT automatically completes cases after 5 minutes. After completing, call
caseResults one final time — this is your single source of truth for the complete result set.
Webhooks follow a fire-and-forget model (no retries). We recommend not relying on webhooks alone for result completeness.
Are all API calls billed?
No. Billing is based on case invocation only — createCase, createBtlCase,
and invokeCaseResults. Retrieving results for completed cases (caseResults,
case, search, etc.) is not charged.
How does activeLenders relate to the expected number of results?
activeLenders indicates which lenders support RESI and/or BTL in the current environment.
To track expected result count for a specific case, use index.total from caseResults
— it reflects the actual number of lenders submitted to for that case, which may differ from the full
active lender list.
How long are screenshotPdfUrl and PDF links valid?
Screenshot URLs (returned in caseResults) are valid for 1 hour.
PDF URLs (delivered via CASE_PDF_CREATED webhooks) are valid for 24 hours.
Download and store these files on receipt for compliance purposes.
Can advisers access a case using the magicLink without a separate MBT login?
Yes. The magicLink enables SSO — the user is authenticated automatically on click.
It is one-time and short-lived. Two integration patterns:
- Use a single company JWT and issue magic links scoped to the case. Good for platform-level access where you manage the session.
-
Onboard each adviser via
createUser+createTokenForBrokerand issue adviser-specific links. Data is locked down to each adviser's account, enabling full per-adviser features.
How much notice is needed before going live on production?
Please give us 2 weeks' notice before your planned production go-live. We need to provision your production API keys and configure account limits before you go live. Contact MBT to begin the process.
What is the webhook signature scheme?
See the Webhooks section above for full details. For new integrations, use
RAW_BODY — it signs the exact wire bytes and is far simpler to implement correctly than
the legacy CANONICAL_JSON method. Set signatureMethod: RAW_BODY when creating
your subscription.
My server's IP is being blocked. How do I get allowlisted?
CloudFront WAF's Bot Control rule (SignalKnownBotDataCenter) blocks requests from known data-centre IP ranges, which catches server-to-server API clients. Contact MBT via the contact form with your IP range to request an allowlist exemption.