UK visa applications fail for a consistent set of reasons — most of them checkable programmatically before submission. This is a reference for HR teams and developers building eligibility or document-checking tooling. It covers the dominant refusal patterns, the underlying rules, and how to surface risk before an application is submitted.
1. Financial evidence timestamp validation
The Immigration Rules (Appendix FM-SE, paragraph 1A) require that funds be held continuously for 28 days, with the statement period ending no more than 28 days before the date of application. Most systems check the balance. Few check the timestamp validity window.
Validation logic:
from datetime import date
def validate_bank_statement(
statement_end_date: date,
funds_available_from: date,
submission_date: date,
required_days: int = 28
) -> dict:
window_ok = (submission_date - statement_end_date).days <= 28
period_ok = (statement_end_date - funds_available_from).days >= required_days
return {
"window_valid": window_ok,
"holding_period_valid": period_ok,
"valid": window_ok and period_ok,
"errors": [
msg for condition, msg in [
(not window_ok, f"Statement end date {statement_end_date} is more than 28 days before submission {submission_date}"),
(not period_ok, f"Funds held for fewer than {required_days} days before statement end"),
] if not condition
]
}
Edge cases to handle:
- Joint accounts: the total balance of a joint account is treated as 50% attributable to the applicant by default. Your validation layer should prompt for clarification when a joint account is used.
- Statement format: UKVI requires statements from financial institutions that meet specific format requirements. A checkbox confirming the source is an official bank statement (not a self-generated PDF) should be part of the document ingestion flow.
2. Salary threshold and CoS consistency checks
The Skilled Worker visa requires meeting two salary thresholds simultaneously:
- The general threshold (£38,700 for most roles in 2026)
- The going rate for the specific SOC code (from Appendix Skilled Occupations)
The failure pattern: the CoS records a total compensation figure that includes non-guaranteed allowances (bonuses, commission), while payslip data shows a lower base salary. UKVI compares these and will refuse if the guaranteed base doesn't meet the threshold independently.
Data sources:
- SOC going rates: Appendix Skilled Occupations — GOV.UK, updated on policy change
- General threshold: Appendix Skilled Worker — changes less frequently
Validation:
def validate_skilled_worker_salary(
soc_code: str,
cos_annual_salary: float,
monthly_base_payslip: float,
soc_going_rates: dict, # {soc_code: min_annual_salary}
general_threshold: float = 38700.0
) -> dict:
annualised_base = monthly_base_payslip * 12
going_rate = soc_going_rates.get(soc_code, 0)
required = max(going_rate, general_threshold)
return {
"meets_threshold": annualised_base >= required,
"cos_base_match": abs(cos_annual_salary - annualised_base) < 500, # tolerance for rounding
"required_salary": required,
"annualised_base": annualised_base,
"warnings": [
"CoS salary includes non-guaranteed allowances" if cos_annual_salary - annualised_base > 500 else None,
f"Base salary £{annualised_base:.0f} below required £{required:.0f}" if annualised_base < required else None,
]
}
The ImmigrationGPT salary threshold lookup exposes the current going rates and general threshold via a query interface, with data ingested weekly from GOV.UK.
3. English language certificate expiry
Valid English language tests (IELTS, PTE Academic, SELT providers) have a two-year validity window from the test date to the submission date. Not the decision date — the submission date.
def is_english_cert_valid(test_date: date, submission_date: date) -> bool:
return (submission_date - test_date).days <= 730
This check is trivially implementable. It catches a category of refusal that generates a high volume of avoidable failures, particularly for Student and Family visa applications. Surface it as a hard blocker on the submission flow, not a warning.
Additional check: confirm the test provider is on the current Appendix SELT approved list. The list changes when UKVI adds or removes providers. Pull from Appendix SELT directly; do not hardcode.
4. Non-disclosure detection
UKVI maintains a consolidated immigration decision record per individual. Any discrepancy between the applicant's declarations and that record — previous refusals, overstays, curtailed leave — is flagged as potential deception, not a standard refusal. The outcome can be a 10-year re-entry ban under paragraph 9.8.
You cannot query UKVI's records via API. The check must be human-in-the-loop, but it should be an explicit, required step with confirmation rather than a buried checkbox.
Implementation pattern:
class DisclosureCheckpoint:
required_disclosures = [
"previous_uk_visa_refusals",
"previous_overstay",
"curtailed_leave",
"deportation_or_removal",
"criminal_convictions",
]
def validate(self, applicant_responses: dict) -> list[str]:
missing = [
field for field in self.required_disclosures
if field not in applicant_responses
]
return missing # Surface these as blocking incomplete fields
If your system is HR-facing (managing employee immigration), the HR file should record any curtailment events as they happen — e.g., when an employee's prior employer lost their sponsor licence. That data needs to surface when the employee makes a future application.
5. Relationship evidence gap detection (family visa applications)
For spouse and partner visa applications, UKVI caseworkers are trained to identify inconsistencies between different pieces of evidence. Automated pre-checks can surface structural gaps before legal review.
Gap detection logic:
def check_relationship_evidence(evidence_bundle: dict) -> list[str]:
gaps = []
if not evidence_bundle.get("cohabitation_proof"):
gaps.append("No cohabitation evidence (tenancy agreement, utility bills, bank statements showing same address)")
if not evidence_bundle.get("separation_period_contact"):
if evidence_bundle.get("period_lived_apart"):
gaps.append("Periods of separation declared but no contact evidence (call logs, messages) provided")
if not evidence_bundle.get("joint_financial_commitment"):
gaps.append("No joint financial commitment evidence (joint account, shared bills)")
stated_meeting = evidence_bundle.get("stated_meeting_date")
earliest_contact_log = evidence_bundle.get("earliest_contact_evidence_date")
if stated_meeting and earliest_contact_log:
if earliest_contact_log < stated_meeting:
gaps.append(
f"Inconsistency: contact evidence starts {earliest_contact_log}, "
f"earlier than stated meeting date {stated_meeting}"
)
return gaps
These are pattern flags, not legal determinations. Output should go to a legal reviewer, not direct to UKVI.
Data sources summary
| Data point | GOV.UK source | Update trigger |
|---|---|---|
| SOC going rates | Appendix Skilled Occupations | Policy change (typically post-Budget) |
| General salary threshold | Appendix Skilled Worker | Policy change |
| Approved SELT providers | Appendix SELT | Provider add/remove decisions |
| Sponsor licence register | UKVI CSV (published weekly Thursdays) | Weekly |
| Financial requirements | Appendix FM-SE | Policy change |
Pull all of these directly from GOV.UK source URLs. Third-party aggregators introduce lag — and in immigration compliance, stale data creates liability.
General information only. Not legal advice. For regulatory compliance, consult a qualified immigration solicitor.









