5 things that surprised me building on HMRC's Making Tax Digital API
Engineering notes from building a real MTD integration: Accept-header versioning, fraud prevention headers, cumulative quarterly updates, OAuth strictness, and sandbox quirks.
5 things that surprised me building on HMRC's Making Tax Digital API
I spent the last while building the HMRC integration for TapTax, a Making Tax Digital (MTD) app for UK sole traders. MTD is the UK government's programme that pushes tax filing out of paper and spreadsheets and into software talking directly to HMRC's APIs.
I have integrated with a fair few third-party APIs. Stripe, Plaid-style banking, the usual. HMRC is its own animal. Some of it is genuinely well designed, some of it caught me completely off guard, and a couple of things cost me a full day each before the penny dropped.
So here are the five things that surprised me most. Each one is the surprise, then the fix, with a short snippet from our actual TypeScript backend. Not tax advice, just engineering notes from someone who has now stepped on the rakes so you do not have to.
1. The API version lives in the Accept header, and getting it wrong is a 406
Most APIs version in the URL: /v2/thing. HMRC versions through content negotiation. You ask for a version in the Accept header, like application/vnd.hmrc.5.0+json, and if you ask for a version that endpoint does not serve, you get a 406 Not Acceptable. No helpful "did you mean v3" message. Just 406.
The part that bit me: different endpoints are on completely different versions at the same time. Obligations is on v3.0, the self-employment cumulative summary is on v5.0, calculations are on v8.0, ITSA status is on v2.0. There is no single "current" version to pin.
The fix was to make the version a required argument on the request wrapper so you can never forget it, and set it per call:
// src/services/hmrcApi.ts
const headers = {
Authorization: `Bearer ${accessToken}`,
Accept: `application/vnd.hmrc.${apiVersion}+json`, // e.g. "5.0"
...hmrcConfig.getFraudHeaders(req),
};
One more trap: versions get withdrawn. Obligations used to answer on v2.0; that now returns a 404, not a 406, so it looks like a missing resource rather than a stale version. When an HMRC call 404s, check the version before you go hunting for a bad path.
2. You have to send HMRC a fingerprint of the device on every call
This one is wild the first time you read the docs. HMRC requires a set of "fraud prevention headers" on every API call: a couple of dozen Gov-Client-* and Gov-Vendor-* headers describing the device, the network, the screen, the timezone, and your own server. It is anti-fraud telemetry, and it is mandatory.
The surprise inside the surprise: the connection method decides which headers are legal. A server-mediated web app and a server-mediated mobile app send different sets, and sending the wrong one is an error, not a nice-to-have. So I branch on it:
// src/config/hmrc.ts
const connectionMethod =
req.platform === 'mobile' ? 'MOBILE_APP_VIA_SERVER' : 'WEB_APP_VIA_SERVER';
if (isMobile) {
// mobile REQUIRES the device user-agent, and must NOT send browser headers
headers['Gov-Client-User-Agent'] = mobileUa;
} else {
// web sends the browser JS user-agent, and must NOT send Gov-Client-User-Agent
headers['Gov-Client-Browser-JS-User-Agent'] = browserUa;
}
There is a great free tool that saves you here: HMRC's Test Fraud Prevention Headers API. It tells you exactly which header is missing or malformed. Build against it from day one.
3. Quarterly updates are cumulative, not "this quarter"
You file four times a year, so the obvious model is "four quarters, four payloads, each holding that quarter's numbers." That is wrong, and it is the kind of wrong that passes your first test and then quietly corrupts everything.
Each quarterly update is cumulative: year-to-date from 6 April, not the delta for the quarter that just closed (gov.uk guidance). The clue is in the HTTP method and the path. You do not POST a quarter. You PUT a summary keyed by the tax year:
// PUT /individuals/business/self-employment/{nino}/{businessId}/cumulative/{taxYear}
// turnover/expenses are running totals since 6 April, NOT just this quarter
await hmrcApi.submitQuarterlyUpdate(nino, businessId, '2026-27', token, req, {
periodDates: { periodStartDate: '2026-04-06', periodEndDate: '2027-01-05' },
periodIncome: { turnover: 42000, other: 0 },
periodExpenses: { costOfGoods: 11000 /* ... */ },
});
Because it is a PUT keyed by the year, it is idempotent. Resending the same snapshot is a no-op, and a correction is just a fresh snapshot of the whole year. The practical rule I landed on: never keep a running counter you increment each quarter. Recompute the year-to-date total from your transaction ledger every time. Derive, do not accumulate.
4. The OAuth state token is single-use, and the redirect URI has to match to the character
The OAuth flow itself is standard Authorization Code, but two details are stricter than I expected.
First, the state token. I treat it as single-use and delete it the moment it comes back, then check it has not expired. This is normal CSRF hygiene, but HMRC's sandbox is unforgiving about replays, so a "works once, fails on refresh/back-button" bug is easy to create if you leave the state lying around:
// src/routes/auth.ts
await deleteStateToken(state); // single-use: delete immediately
if (Date.now() - stateToken.createdAt > 10 * 60 * 1000) {
redirectError(platform, 'Authorization request has expired. Please try again.');
return;
}
Second, the redirect_uri. It has to match what you registered on the HMRC Developer Hub exactly. Same scheme, same host, same path, trailing slash and all. A mismatch does not warn you, it just fails the token exchange. I keep mine in one config value and reuse it for both the authorize URL and the token POST so the two can never drift apart.
5. The sandbox is fussy in ways production never warns you about
Two sandbox gotchas ate hours, and both are about request shape rather than data.
A GET with Content-Type: application/json and no body gets rejected by HMRC's CloudFront edge as a 403 Bad request. Every read endpoint broke at once. The fix is to only set the JSON content type when there is actually a body:
// src/services/hmrcApi.ts
if (data !== null && data !== undefined) {
headers['Content-Type'] = 'application/json'; // never on a bodyless GET
}
The mirror image: some POSTs that take no meaningful body 500 if you send null, but accept an empty object. Triggering a calculation is the classic one:
// {} not null: a null-bodied POST 500s on HMRC's calc backend, {} is accepted
await hmrcApi.triggerCalculation(nino, taxYear, token, req); // sends {}
While you are in the sandbox, two more things worth knowing. Use the Gov-Test-Scenario header to drive deterministic responses, because the no-scenario defaults can hand you stale data from old tax years that newer endpoints then reject. And wrap your calls in a retry with backoff: the sandbox throws transient 429s and 5xxs that have nothing to do with your code.
// retry on 429 + 500/502/503/504 + network errors, exponential backoff
return [429, 500, 502, 503, 504].includes(status);
The takeaway
None of this is unreasonable once you see why it exists. HMRC is moving the entire UK tax base onto software, and the friction is mostly anti-fraud and backwards-compatibility showing through the API surface. The trick is to stop reasoning by analogy with friendlier APIs: read the version off each endpoint, lean on the header validator, model state instead of events, and treat the sandbox as a strict reviewer rather than a forgiving toy.
If you are about to start an MTD integration: pin versions per endpoint, build against the fraud-header validator on commit one, make your submissions idempotent, and budget a day for the sandbox to teach you its quirks. That is most of the pain, paid down up front.
Solomon Amos is the founder of TapTax, a Making Tax Digital app for UK sole traders. He built TapTax's HMRC integration, spent two years embedded at HMRC's digital programmes, and holds a PhD in machine learning. The code above is from TapTax's own integration; this is an engineering write-up, not tax advice. linkedin.com/in/solomonudoh