Send your Krewfield API token (created under Settings -> API Access) on
the X-Api-Key header of every request:
X-Api-Key: kf_live_...
A missing, invalid, expired, or revoked token comes back as a plain
401 unauthorized. A valid token is a service account belonging to your
Krewfield account (role service_account), so everything below about
permissions, departments, and the account-status gate applies to it exactly
the way it does for a teammate.
The only endpoint that needs no token is GET /health, a plain reachability
check. Everything else needs the token.
A token expires 365 days after it's created or regenerated. Regenerate issues a new secret and stops the old one working immediately; the token's permissions and department access are kept, so nothing needs re-granting. The secret is shown exactly once, when it's created or regenerated - Krewfield only stores a hash of it, so a lost token can't be looked up, only replaced.
Revoke a token from the same settings page to stop it working at once. Revoking is final: a revoked token can't be regenerated or brought back - create a new one.
Endpoints check one permission key each - e.g. can_manage_jobs,
can_view_pricing, can_record_payments - never a role name directly. When
you create a token you choose which permissions it gets, and you can change
them later with Edit permissions without touching the secret. Grant only
what the integration needs.
A token can never hold more than the person who created it holds: a
permission its creator lacks is refused (permission_escalation), so the
token can't be used to widen anyone's access. (The account owner can grant any
permission a token is allowed to hold.)
Some permissions can never be granted to a token, however it's configured:
can_manage_team, can_manage_billing, can_manage_account_settings, and
can_manage_api_access. Team, billing, account settings, and token
management are human-only.
GET /account/me returns the token's own effective permissions array -
the quickest way to check what a token can do. Don't build a client that
assumes a capability it hasn't seen in that array: the server enforces the
real check independently on every request.
If the account uses departments, a token is scoped either to all departments or to a specific set, chosen at creation. Requests for a record outside that scope are refused the same way they are for a department-limited teammate. A token can't be given wider department access than the person who created it has.
Two account-level conditions are checked on every authenticated call, before your specific permission:
suspended / cancelled - every write and read is blocked
(403 account_disabled).403 trial_expired); reads still
work, so a lapsed trial account can still see its own data.A past_due billing status is deliberately not gated here - it's a
grace period, surfaced to the account as a banner instead of a hard block.
Every endpoint that fails returns the same JSON shape, whatever the status code:
{
"status": "error",
"error": "forbidden",
"message": "You don't have permission to manage jobs"
}
message is always present — a plain, safe-to-display sentence.
Show it as-is if you don't recognize error.error is a machine-readable slug for a specific, recognized
failure — forbidden, not_found, trial_expired, validation_error,
duplicate, and so on, each endpoint's own reference entry lists
exactly which ones it can return and why. It's absent on a generic
unexpected failure (a plain 500), which carries only message.A few status codes recur across almost every endpoint with the same meaning, so they're worth knowing cold rather than re-deriving each time:
| Status | Typical error | Meaning |
|---|---|---|
| 400 | validation_error | The request body/query is malformed or fails a business rule the endpoint checks up front (e.g. a required field is missing). |
| 403 | no_account | The token is valid but resolves to no active user row — rare, usually a deprovisioned account. |
| 403 | forbidden | Authenticated, but missing the specific permission (or department access) this endpoint requires. |
| 403 | account_disabled | The account is suspended or cancelled. |
| 403 | trial_expired | A trial account past its end date, on a write. |
| 404 | not_found | No such record in your account — an id that exists in another account looks identical to one that doesn't exist at all. |
| 409 | (varies) | A state conflict specific to that endpoint — e.g. duplicate, already_paid, bad_transition, client_archived (restore the client first), stale_tax_config (the settings changed since you loaded them), duplicate_client. Check the endpoint's own reference entry. |
A 409 conflict occasionally carries extra fields alongside the standard
three — e.g. archiving a department that still has open work returns the
counts blocking it, so the UI can link straight to what needs clearing
first. That's called out explicitly wherever it applies, in each
endpoint's own error description below.
A handful of patterns repeat across almost every domain in this API. Knowing them up front makes the per-endpoint reference read faster, since they're not re-explained on every single operation.
Every amount — total, unit_price, balance, amount — travels as a
string, e.g. "149.00", never a JSON number. Sending a float risks
floating-point rounding on a value that has to reconcile exactly; a string
doesn't. The server recomputes every total authoritatively on save — it
never trusts a total the client sent, only the line items/quantities that
produce one.
"Deleting" a client, job, quote, invoice, pricebook item, and most other
entities sets archived_at rather than removing the row — reversible via
that same entity's .../restore endpoint. The main list endpoint for an
entity hides archived rows by default and exposes an ?archived=1-style
filter to see them.
The exceptions are genuinely ephemeral records: a note, once deleted, is
gone (append-and-hard-delete is the whole model — there's no note-editing
endpoint either). An invoice or payment ledger row is a financial record
and is never deleted at all, soft or hard, once it's left draft — it can
only be voided, written off, or refunded.
"Job #47", "Quote #12", "Invoice #3" — the number you see is
count(*) WHERE id <= this within the account (or within the department,
for a department-scoped number), computed on every read. There's no
quote_number counter column to get out of sync. The raw internal id is
what you actually address the record by in the URL/body; the number is
display-only.
An account can optionally split into departments (a trade/crew
separation feature — most small accounts never turn it on and only ever
have the one default department). When it's on, a caller whose
department_scope isn't "all" only sees and can only act on records in
the departments they're assigned to — enforced server-side on every
affected endpoint, not just hidden client-side. GET /departments and
GET /account/me are how the frontend discovers whether this even applies
to the current account.
Most list endpoints (GET /jobs, GET /invoices, GET /clients, ...)
share the same query-parameter shape: ?limit= (a sane default, capped
per endpoint — check its own reference entry for the cap), ?offset=, and
a small set of entity-specific filters. The response is
{ <entity>: [...], total, limit, offset } — total is the full matching
count, not just the page size, so a client can render "Page 2 of 9"
without a second request.