Authentication & permissions

Sending the token

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.

Token lifetime and rotation

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.

Permissions

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.

Department access

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.

The account-status gate

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).
  • Trial expired - writes are blocked (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.

Errors

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:

StatusTypical errorMeaning
400validation_errorThe request body/query is malformed or fails a business rule the endpoint checks up front (e.g. a required field is missing).
403no_accountThe token is valid but resolves to no active user row — rare, usually a deprovisioned account.
403forbiddenAuthenticated, but missing the specific permission (or department access) this endpoint requires.
403account_disabledThe account is suspended or cancelled.
403trial_expiredA trial account past its end date, on a write.
404not_foundNo 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.

Conventions

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.

Money is always a decimal string

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.

Soft delete, almost everywhere

"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.

Numbers shown in the UI are computed, not stored

"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.

Department scoping

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.

List endpoints: pagination and filtering

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.