Errors
Who this is for: Integrators who branch on HTTP status and error.code instead of parsing free-text messages.
When you finish: You know why Takehome rejects bad input, why Quebec never returns a fake zero-tax stub, and why unknown JSON keys fail fast.
A claim code is the TD1 box value that maps personal amounts to federal and provincial credits. Errors never return partial employee amounts.
Every error our code returns is ErrorEnvelope:
{
"error": {
"code": "date_out_of_range",
"message": "as_of 2025-12-31 is before coverage begins on 2026-01-01.",
"docs": "https://takehome.gautamkhosla.com/what-is-the-t4127"
},
"rule_set_version": null,
"engine_version": "0.1.0",
"engine_build_sha256": "1704657a70ddd0a693d72dc9355f46732bafeb4a70228c30c2a8afd3c0ee0aec"
}error always has exactly code, message, and docs (HTTPS link). There are no stack traces, SQL fragments, or internal paths in the body.
Answers that are not ours: every API path is behind Cloudflare's per-IP rule, at most 10 requests per 10 seconds from one IP address, counted before our code runs. Above it, Cloudflare answers HTTP 429 with its own page for 10 seconds. That body is not JSON and has no error.code, so check the status before parsing: on a 429 that does not parse as JSON, wait 10 seconds and retry. One IP address gets at most 60 API requests a minute in all. Cloudflare also answers with its own pages, not JSON, when the whole service has used its Workers Free requests for the day (Cloudflare error 1027; retry after 00:00 UTC, not every 10 seconds) and when one request runs past the CPU limit (error 1102). We have not checked the HTTP status of those two pages, so do not rely on it: if a page that is not JSON keeps coming back for more than a minute, retry after 00:00 UTC.
Design choices
Typed codes, stable HTTP mapping
Each failure maps to one error.code and a documented HTTP status. Handlers use classifyEngineError so the engine's unknown field error becomes unknown_field and date coverage gaps become date_out_of_range. Clients should switch on code, not substring match message.
Quebec is unsupported, not zero tax
province: "QC" returns jurisdiction_not_supported (HTTP 422). Takehome does not return federal-only tax with provincial_tax forced to zero. QPIP (Québec Parental Insurance Plan) and QPP are not approximated. Billing does not count a failed QC call as a successful live calculation.
Dates are rejected, not snapped
When as_of is before the first enacted rule set or after coverage ends, the API returns date_out_of_range (HTTP 400). It does not pick the closest January or July edition. The same strict behavior applies to GET /v1/rules/{version} for unknown versions (not_found, HTTP 404).
Unknown fields are rejected, not ignored
Extra JSON keys fail with unknown_field (HTTP 400) on every route, and message names the key. In a calculation the engine names the typo; the batch envelope takes only requests, so a claim code or as_of placed next to requests instead of on each item is refused, never ignored; the account and webhook routes take only their documented fields. A wrong value is invalid_request naming its field, even when the value itself contains the words "unknown field". Silent ignore would let integrators ship wrong withholding for weeks before anyone notices.
The same holds for documented fields the engine does not read (prior_province, date_of_birth, cpp_election_after_65, commission_income, commission_expenses, ytd_qpp, ytd_qpp2 and ytd_qpip): each is invalid_request naming the field and why, on every calculation route, whenever it is sent with a value. Claim codes 2 to 10 are read from the CRA claim-code table of the as_of edition; where that table cannot serve the request, the code is refused by name, never read as code 1.
The same holds on POST /v1/deductions/year: the engine first reads the whole body exactly as POST /v1/deductions does (same codes, same messages). A documented field the year cannot apply to every period (the year-to-date totals, bonus and retroactive pay fields, commission fields, Quebec and change-of-province fields, date_of_birth, cpp_election_after_65, calculation_option option2) is invalid_request naming the field and saying why; it is never ignored.
Fail closed on metering storage
If the database fails, live keys receive store (HTTP 503) and no calculation is served. See ADR-005. Test keys stay unmetered but still need the database to look the key up, so they get store too, and so does every other route that reads the database (keys, signup, verification, webhooks).
Batch partial success
POST /v1/deductions/batch returns HTTP 200 when the envelope is valid even if some items fail. Each slot is { "ok": true, "response": … } or { "error": { … } }. Whole-batch rejection happens for batch_too_large (more than 25 items), plan_limit, or malformed batch shape.
Common codes
| Code | HTTP | Typical cause |
|---|---|---|
unknown_field |
400 | Typo in JSON property name |
invalid_request |
400 | A value of the wrong type or outside its field's values (an illegal pay period, a claim code above 10, cpp_months 256, a money amount sent as a JSON number), a required field left out, ambiguous claim code plus TC, a claim code from 2 to 10 the edition's claim-code table cannot serve (July 2026 NL under Option 2), a documented field the engine does not read, a negative amount in any money field (the message names the field), an amount with more than 15 digits before the decimal point or more than 2 decimal places, a whole number out of its range (cpp_months 0 to 12, pay_periods_elapsed 0 to pay_period, dependants 0 to 99), a field the year projection cannot apply or a year whose pay dates would pass 31 December, bad or too long webhook URL, revoke without an exact kind |
date_out_of_range |
400 | as_of outside enacted coverage |
batch_too_large |
400 | A batch of more than 25 calculations (the Workers Free CPU limit); nothing is calculated or counted |
year_too_large |
400 | A year projection of more than 27 pay periods (weekly, daily or hourly); nothing is calculated or counted |
payload_too_large |
413 | A body over 16 KiB (48 KiB for a batch, 8 KiB for signup and verify) |
jurisdiction_not_supported |
422 | Quebec or other unsupported code |
unauthorized |
401 | Missing or revoked API key |
plan_limit |
402 | Live monthly calculation cap reached |
rate_limited |
429 | Too many HTTP requests, a test key past 5,000 calculations a minute for its account, or a per-account daily budget used up (a 429 without JSON is Cloudflare's per-IP rule, above) |
engine |
500 | Internal engine failure (generic message) |
invalid_request |
409 | Something the request writes to was deleted while it ran (your own endpoint or account deletion at the same moment); nothing was completed. Also: a sixth endpoint, or URLs over 16,384 bytes in all (POST /v1/webhooks), and a dispatch from an account with more than 5 endpoints, and a dispatch or replay while another of the same account is still running (one at a time per account; nothing sent or counted; retry when it has answered, at most 60 seconds) |
store |
503 | Database outage (metering, keys, signup, webhooks); retry later |
Full list with remediation steps: api-reference.md.
Batch item errors reuse the same error object shape but omit top-level identity inside the slot (only error on failure items).
Last reviewed: 2026-10-24
Engine: 0.1.0