Docs · Concepts

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

Source: docs/errors.md in the Takehome repository, rendered as written.