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
| Step | Dashboard calls | HMRC API (Accept version) |
|---|---|---|
| 1 Connect | /auth/hmrc/initiate → /auth/hmrc/callback | OAuth with read:self-assessment and write:self-assessment |
| 2 Business | POST /api/v2/hmrc/businesses/fetch | Business Details (2.0) |
| 3 Obligations | POST /api/v2/hmrc/obligations/fetch | Obligations (3.0) |
| 4 Check figures | GET /api/v2/tax/hmrc/aggregate-preview | None: totals classified transactions locally |
| 5 Preview | POST /api/v2/tax/hmrc/calculate-preview | Self-Employment cumulative (5.0), Individual Calculations (8.0) |
| 6 Declare | POST /api/v2/tax/hmrc/submit-final-declaration | Individual Calculations (8.0) final declaration |
Layers
lib/hmrc-mtd-client.lua: one function per HMRC endpoint.HMRC_ENVIRONMENTswitches betweentest-api.service.hmrc.gov.ukandapi.service.hmrc.gov.uk; every request carriesAccept: 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.luaandtax-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:
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.
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_certificatepointing at the system CA bundle (already in the shipped config). HMRC_CLIENT_ID,HMRC_CLIENT_SECRET,HMRC_REDIRECT_URIandHMRC_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.