OpsAPI's tax module (tax_copilot) can file a UK Self Assessment return end to end against HMRC's Making Tax Digital (MTD) APIs: connect, find the business, find what's due, check the figures, preview the tax, then make the binding final declaration. This note maps each step to code and lists the HMRC behaviour that costs the most time to rediscover.

The wizard

The six steps of the File your tax wizard, five non-binding and one bindingnon-binding: nothing here files a returnbinding1 ConnectHMRC sign-in (OAuth)you click2 BusinessBusiness Details v2runs itself3 ObligationsObligations v3runs itself4 Check figurestotals + fixesruns itself5 Previewcumulative + in-yearyou click6 Declarefinal declarationcheckbox + clickGreen steps run on their own as soon as the step before them has an answer;only the steps that need a person ask for a click.
Steps 2–4 start themselves through guarded effects in the dashboard (one run per business or tax year). Step 6 is the only binding call, and it's gated on a declaration checkbox.
StepDashboard callsHMRC API (Accept version)
1 Connect/auth/hmrc/initiate → /auth/hmrc/callbackOAuth with read:self-assessment and write:self-assessment
2 BusinessPOST /api/v2/hmrc/businesses/fetchBusiness Details (2.0)
3 ObligationsPOST /api/v2/hmrc/obligations/fetchObligations (3.0)
4 Check figuresGET /api/v2/tax/hmrc/aggregate-previewNone: totals classified transactions locally
5 PreviewPOST /api/v2/tax/hmrc/calculate-previewSelf-Employment cumulative (5.0), Individual Calculations (8.0)
6 DeclarePOST /api/v2/tax/hmrc/submit-final-declarationIndividual Calculations (8.0) final declaration

Layers

  • lib/hmrc-mtd-client.lua: one function per HMRC endpoint. HMRC_ENVIRONMENT switches between test-api.service.hmrc.gov.uk and api.service.hmrc.gov.uk; every request carries Accept: application/vnd.hmrc.<version>+json.
  • helper/hmrc.lua: OAuth token storage and refresh, and the anti-fraud headers.
  • lib/hmrc-aggregator.lua: rolls classified transactions into the cumulative body and flags negative fields, usually a credit filed as an expense, which HMRC rejects. Step 4 lets the user fix them inline.
  • Routes in tax-hmrc-auth.lua, tax-hmrc-data.lua and tax-hmrc-filing.lua.

The final declaration

The declaration must reference an intent-to-finalise calculation, not the in-year one from step 5. So the route triggers a fresh intent-to-finalise calculation, waits for it, then posts the declaration against that id. It refuses to run at all unless the request carries declaration_accepted: this call is binding. Real HMRC also rejects finalising a tax year before it has ended (RULE_FINAL_DECLARATION_TAX_YEAR); the sandbox lets it through.

Sandbox behaviour worth knowing

Obligations ignore your dates unless you ask

By default the sandbox returns a fixed 2018-era sample whatever from and to you send. Gov-Test-Scenario: DYNAMIC makes it echo periods for the dates requested. It then returns synthetic business ids for all three business types (XBIS… self-employment, XPIS… UK property, XFIS… foreign property), so OpsAPI keeps the self-employment ones and doesn't filter by your real business id in the sandbox. The header is dropped outside the sandbox.

Calculations only work against a test business

Cumulative submissions are sent with Gov-Test-Scenario: STATEFUL, and stateful calculations only work for a business created through the Self Assessment Test Support API. The businesses Business Details hands back aren't fileable, and HMRC answers MATCHING_RESOURCE_NOT_FOUND. Both the preview and the declaration routes heal themselves:

In the sandbox, a not-found error provisions a test business and retries oncecalculate-previewOpsAPI routeHMRC calculationCalculations v8Successfigures parsedNot foundMATCHING_RESOURCE_NOT_FOUNDProvision test businesscreate + set ITSA statusstore as default business, retry oncesandbox only
The self-heal creates a test business, sets its MTD ITSA status, stores it as the default business and retries once. Outside the sandbox the error is returned as is.

The figures are nested

Tax figures sit under calculation.taxCalculation, with sub-totals in .incomeTax and allowancesAndDeductions, not at the top level. parse_figures reads those paths. The sandbox always returns -99999999999.99 as the grand total; OpsAPI flags it as a placeholder so the dashboard doesn't show it as a real bill. The other figures are real.

Anti-fraud headers

HMRC requires Gov-Client-* and Gov-Vendor-* headers on every call. Some signals only exist in the browser, so the dashboard collects them and sends them as X-Gov-Client-*; build_fraud_headers() maps them to the real names and adds the server-side ones.

Anti-fraud signals travel from the browser through OpsAPI to HMRCBrowserscreens, timezone,window size, user agentDashboardlib/hmrc-fraud.tssends X-Gov-Client-*OpsAPIbuild_fraud_headers()adds IPs + vendorHMRCGov-Client-*Gov-Vendor-*The X-Gov-Client-* names are allowed through CORS so the browser can send them.
The connection method is WEB_APP_VIA_SERVER. Gov-Vendor-Public-IP and Gov-Vendor-Forwarded need the server's public egress address configured; they're left out when it isn't, because HMRC rejects private addresses.

These headers pass HMRC's Test Fraud Prevention Headers validator. The one remaining note is a non-blocking Gov-Client-Multi-Factor warning, which is acceptable for sign-in with a password and an emailed code.

Production readiness

Switching HMRC_ENVIRONMENT to production isn't enough on its own. You also need:

  • Verified TLS: requests verify certificates outside the sandbox, so nginx needs lua_ssl_trusted_certificate pointing at the system CA bundle (already in the shipped config).
  • HMRC_CLIENT_ID, HMRC_CLIENT_SECRET, HMRC_REDIRECT_URI and HMRC_SERVER_PUBLIC_IP.
  • The HMRC application subscribed to Business Details, Obligations, Self Employment Business and Individual Calculations (MTD).

Testing without a browser: the routes need an OpsAPI user with a stored HMRC token. The repo's CLAUDE.md describes minting a test JWT for such a user and syntax-checking Lua inside the container.