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:

  1. Residential (RESI)createCase or createCaseDraftinvokeCaseResults
  2. Buy-to-let (BTL)createBtlCase or createBtlCaseDraftinvokeCaseResults
  3. Webhook push — receive result and PDF events via createOrUpdateWebhookSubscription

Release Notes

Deployments follow the branch pipeline: main (nightly) → stagingrelease (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 cases query with limit/offset args (max 40), and a new search query returning PaginatedCases { totalCount, cases }. Searches across case UUID, client name, and reference.
  • New lenders query with active, limit, offset, and type (RESI / BTL) args. Replaces ad-hoc filtering on activeLenders, which is retained for backward compatibility.
  • New magicLink mutation returning { url, expiresAt }. Action enum values: CRITERIA, RESI, BTL, SOURCING, VULNERABLE.
  • Case type gains: adviser, applicant (new ApplicantShort type), office, createdAt, lastUpdatedAt, caseType.
  • Lender type gains: btlName, imageResi, imageBtl, referral, address, contact, disclosure. New objects: LenderReferral, LenderAddress, LenderContact, Disclosure.
  • CaseResult gains lenderProducts: [LenderProduct!]! with new object LenderProduct (id, code, mortgageClass, initialRateYear, initialRate, bestBuy) and enum MortgageClass (Fixed, Variable, Tracker, Discount).
  • Credit checks: new CreditCheck / CreditCheckDraft input objects (consentGivenAt: DateTime!, residenceHistory: [ResidenceHistory!]!) and a creditCheck field on all applicant input and response types. Three years of residence history required.
  • Foreign national support: foreignNational, foreignStatus, foreignNationalResidence, and foreignNationalResidenceYearsRemaining added to RESI and BTL personal details. New enums ForeignStatus and ForeignNationalResidence.
  • Loan.ownership enum gains RIGHT_TO_BUY and FIRST_HOMES_SCHEME.
  • Loan.reason enum gains LET_TO_BUY.
  • Loan gains purchaseDiscount; BTL Loan gains multiblockUnits.
  • Income gains bonusYearThree (annual bonus, year 3).
  • Expenditure enums gain EV_SALARY_SACRIFICE for both RESI and BTL.
  • AdditionalIncome.income_type gains RENT_ROOM_SCHEME.
  • PersonalDetails gains rentTenant (monthly rent paid if applicant is currently a tenant).
  • CaseResult.amount / amountMax semantics clarified: amount is the lend amount; amountMax is the maximum based on product and income.

Webhook changes

  • New signatureMethod arg on createOrUpdateWebhookSubscription. Enum WebhookSignatureMethod: CANONICAL_JSON (legacy, default) and RAW_BODY (recommended). Existing subscriptions remain on CANONICAL_JSON until explicitly migrated.
  • RAW_BODY mode signs the exact wire bytes. Headers: X-Webhook-Timestamp (Unix seconds) and X-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, only pdf and reference are present.
  • Note: case_pdf_created payload does not carry reference — that field is emitted only on criteria_pdf_created.
  • Subscription response shape gains the signatureMethod field.

Environments

EnvironmentWebAPI endpointGraphiQL
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.

BrokerCompanyIntegration
Privilege LowestMidHighest
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

Your token (pre-filled in curl examples below):
<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.

▶ Open GraphiQL

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.

createCaseDraft — full example payload (2 applicants) Copied!

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)

updateCaseDraft Copied!

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

invokeCaseResults Copied!

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.

caseResults query Copied!

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 } ] } }
completeCase — finalise and trigger PDF Copied!

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.

createUser — add a broker account Copied!

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" } } }
createTokenForBroker — get a JWT for an adviser Copied!

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:

createBtlCase Copied!

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.

activeLenders Copied!

Query:

query { activeLenders { name reference resi btl } }

Variables:

{}

lenders

Returns lenders with optional filtering by segment and active state.

ArgumentTypeDefaultDescription
activeBooleanFilter to active or inactive lenders only.
limitInt20Maximum number to return.
offsetInt0Number to skip.
typeLenderSegmentTypeRESI or BTL. Omit for all.
lenders Copied!

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.

ArgumentTypeRequired
uuidStringYes
case Copied!

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.

caseResidential Copied!

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.

caseBtl Copied!

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.

ArgumentTypeRequired
uuidStringYes — the case UUID
caseResults Copied!

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.

ArgumentTypeDefaultMax
limitInt2040
offsetInt0
cases Copied!

Query:

query Cases($limit: Int, $offset: Int) { cases(limit: $limit, offset: $offset) { uuid status caseType createdAt } }

Variables:

{"limit": 20, "offset": 0}

Searches cases by UUID, client name, and reference. Returns paginated results with a total count.

ArgumentTypeRequiredDefault
queryStringYes
limitIntNo20 (max 40)
offsetIntNo0

currentCompany

Returns the company for the authenticated token. Requires a broker or company token.

currentCompany Copied!

Query:

query { currentCompany { name fcaNumber } }

Variables:

{}

caseStatistics

Returns aggregated statistics for a given case type and value.

ArgumentTypeRequired
typeCaseStatisticTypeYes
valueStringYes
caseStatistics Copied!

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.

ArgumentTypeDescription
groupStringFilter by ONS group name.
codeIntFilter by ONS code.
nameStringFilter by occupation name.
firstInt (default 20)Limit results.
offsetInt (default 0)Skip results.
occupations Copied!

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.

createTokenForBroker Copied!

Mutation:

mutation CreateTokenForBroker($email: String!) { createTokenForBroker(email: $email) }

Variables:

{"email": "adviser@example.com"}

createTokenByEmail (requires company token)

Creates a broker token by email address.

createTokenByEmail Copied!

Mutation:

mutation CreateTokenByEmail($email: String!) { createTokenByEmail(email: $email) }

Variables:

{"email": "adviser@example.com"}

createTokenByUuid (requires company token)

Creates a broker token by user UUID.

createTokenByUuid Copied!

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.

ArgumentTypeDescription
fca_numberStringThe company's FCA number.
company_numberStringCompanies House number.
domainStringDomain excluding scheme (e.g. example.co.uk).
emailStringEmail of an active member of the company.
uuidStringCompany UUID.
createTokenForCompany Copied!

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.

createUser Copied!

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.

ArgumentTypeRequired
companyCompanyInputYes
admin_userAdminUserInputYes
send_onboarding_emailBooleanNo
createCompany Copied!

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

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:

ActionDescription
RESIResidential case view
BTLBuy-to-let case view
CRITERIACriteria lookup
SOURCINGSourcing
VULNERABLEVulnerable customer flow

Residential Cases

createCase

Creates a residential case and immediately invokes affordability. Preferred when all data is available upfront. Charged per invocation.

ArgumentTypeRequired
loanLoanYes
applicants[Applicant!]!Yes
properties[Property!]!Yes
preferencePreferenceNo
createCase Copied!

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.

createCaseDraft Copied!

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.

updateCaseDraft Copied!

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.

ArgumentTypeRequired
loanBtlLoanYes
property_costBtlPropertyCostYes
personal_detailsBtlPersonalDetailsYes
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.

ArgumentTypeRequired
case_uuidStringYes
new_clientBooleanYes
depersonalizeBooleanYes
advisor_uuidStringNo

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.

ArgumentTypeRequiredDescription
urlStringYesHTTPS endpoint to receive events.
topics[WebhookTopic!]!YesCASE_RESULTS, CASE_PDF_CREATED, CRITERIA_PDF_CREATED.
secretStringNoShared secret for HMAC signing. Generate with a CSPRNG.
activeBooleanNoDefault true.
signature_methodWebhookSignatureMethodNoRAW_BODY (recommended) or CANONICAL_JSON (legacy default).
createOrUpdateWebhookSubscription Copied!

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.

caseResults subscription Copied!

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

TopicWhen it fires
CASE_RESULTSAs each lender result arrives during affordability calculation.
CASE_PDF_CREATEDWhen the case PDF is ready (generated after completeCase, approx. 5 minutes after invocation).
CRITERIA_PDF_CREATEDWhen 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 signedRaw HTTP body bytes, as transmitted on the wireFlattened, sorted, lowercased JSON string
Timestamp unitSeconds (in X-Webhook-Timestamp header)Nanoseconds (embedded in X-Webhook-Signature as t=)
Signature headerv1=<base64>,alg=sha512 (no t=)t=<ns>,v1=<base64>,alg=sha512
JSON library sensitivityNoneHigh — 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' }) before express.json(); use req.body (a Buffer).
  • FastAPI: raw = await request.body() before request.json().
  • Go: read r.Body into a byte slice once, pass the same bytes to both verifier and json.Unmarshal.
  • AWS Lambda: use event.body string before JSON.parse; base64-decode first if isBase64Encoded is 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:

  1. JSON-decodes the body into a map.
  2. Recursively flattens to dotted/indexed key=value pairs (nested maps: a.b=1; lists: xs[0]=10).
  3. Sorts pairs lexicographically (byte/ordinal order — not locale-aware).
  4. Joins with ,.
  5. 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 groupLimit
Affordability (createCase / invokeCaseResults)180 requests / minute
Result streaming (WebSocket / caseResults)120 active sessions / minute
All other requests600 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:

  1. Poll caseResults every 5–10 seconds. Use index.total to know how many results to expect. Most integrations use this approach.
  2. WebSocket subscription — subscribe to caseResults for real-time streaming as results arrive.
  3. Webhook push — subscribe to CASE_RESULTS to 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 + createTokenForBroker and 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.