The custom-fields plugin exposes its entire surface under one base path on the Vikunja server:
/api/v1/plugins/custom-fields
Every request and response body is JSON (Content-Type: application/json)
except the management UI routes, which serve HTML and JavaScript. Timestamps
are not part of the API surface. This document describes the plugin's API as
shipped; for installation and configuration see
installation.md.
All API routes except the two management-UI routes require authentication:
Authorization: Bearer <jwt>
This is the same token the Vikunja web app uses — it is held in the browser's
localStorage under token after login. There is no separate plugin
authentication.
API tokens are accepted when scoped correctly. A tk_… API token
authorizes a plugin route only if its permissions include the plugins group
entry for that exact route and method — every plugin endpoint is listed as its
own entry under the plugins group in GET /api/v1/routes, the same list the
token-creation UI offers. A token without the matching permission is rejected
with 401, like any invalid credential. Requests made with an API token act
as the token's owner, so the management whitelist and task permissions apply
to that user exactly as they do for JWT requests.
Two independent gates apply, depending on what a request touches:
- Field definitions (create, read, update, delete, impact previews) are
gated on the management whitelist — the config key
customfields.whitelist(seeinstallation.md). Non-managers receive403 Not permitted to manage custom fields. - Field values follow the task's own permissions: reading values
requires the task to be readable, writing values requires the task to be
writable, exactly as
models.Task.CanRead/CanUpdatedecide it. The whitelist plays no role here — anyone who can edit a task can edit its custom field values.
Additionally, every value write checks assignment: the field must be
assigned to the task's project (specifically, or globally). Writes to a field
that does not apply to the task's project fail with 400.
Errors are returned as JSON with a human-readable message. Errors raised by
Vikunja's own auth middleware (malformed/missing JWT) carry the host's
standard shape with a numeric code; errors raised by the plugin carry only
message:
{"message": "custom field definition 42 not found"}The plugin's internal error codes (9001–9015) are not exposed on the wire. Status codes and messages:
| Status | When | Message pattern |
|---|---|---|
| 400 | Malformed body or path parameter | invalid request body, invalid id, invalid task id, invalid field id, invalid project_id |
| 400 | Definition validation failure | custom field name must not be empty, invalid custom field type: <type>, options are only allowed for select/multiselect, not <type>, duplicate option value: <value>, constraint min/max is not valid for type <type>, invalid constraint: <detail>, project <id> does not exist, a field is either global (all projects) or assigned to specific projects, not both |
| 400 | Value validation failure | invalid value for <type> field: <detail>, value for a required field must not be empty, option value "<value>" is not a valid option for this field, field is not assigned to this task's project |
| 401 | Missing or invalid JWT | unauthorized |
| 403 | Authenticated but not permitted | not permitted to manage custom fields (definitions), no access to this task (value reads), no write access to this task (value writes) |
| 404 | Resource absent | custom field definition <id> not found, custom field value not found for field <id> on task <id>, task <id> not found (only via a race between the permission check and the task lookup — a missing task normally surfaces as 403), value not found |
| 409 | Create when a value already exists | custom field value already exists for field <id> on task <id> |
| 500 | Database or unexpected failure | Passes through the internal error text |
Definitions declare one of ten types. The value JSON you send must match the
type; the value you read back is the same value in its native JSON form:
| Type | Write | Read back | Rules |
|---|---|---|---|
text |
string | string | Whitespace-only counts as empty |
textarea |
string (rich-text HTML) | string | Stored as sent |
url |
string | string | Must parse as a URL with a scheme (https://…, not example.com/…) |
integer |
number or decimal-free numeric string | number | Must be an int64; rejects 1.5; honors min/max |
decimal |
number or numeric string | number | Parsed as float64; honors min/max |
date |
"2026-09-06" |
string | Strict YYYY-MM-DD |
datetime |
"2026-09-06T12:00:00Z" |
string | Strict RFC 3339 (timezone required) |
checkbox |
true/false (or their string forms) |
boolean | |
select |
one option value string | string | Must match a current option value |
multiselect |
array of option value strings | array of strings | Each element must match a current option value; duplicates pointless |
Value rules that apply across types:
- Required fields (
field_config.required) reject empty values — empty strings, and empty arrays formultiselect— with400. - Read-as-null policy. A value that is absent, stored as an empty string,
or can no longer be coerced to the field's type (e.g. after a removed select
option or a numeric range change) reads back as
null. Reads never fail because of stale stored data. - Clearing by writing an "empty" value only works for
textandtextarea(empty string), and for non-requiredselect/multiselect(empty string / empty array). For the remaining types an empty write fails validation with400— use the DELETE endpoint to clear any value. A stored empty value reads back asnull. field_config.defaultandfield_config.is_api_onlyare not enforced by the API — they are stored and returned. The fork's task UI honorsis_api_onlyby rendering the field as display-only.
Returned by create, single read, and update. project_ids empty means the
field is global (all projects). options is always present, empty for
non-select types:
{
"id": 1,
"name": "Estimate",
"type": "decimal",
"description": "Effort estimate",
"field_config": {
"required": false,
"default": "",
"is_api_only": false,
"min": 0,
"max": 100
},
"display_order": 1,
"options": [],
"project_ids": []
}field_config always carries required, default, and is_api_only;
min and max appear only when set (integer/decimal types only). Option
objects look like:
{
"id": 3,
"custom_field_definition_id": 1,
"value": "high",
"label": "High",
"display_order": 0
}value is the stored identifier sent with select/multiselect values; label
is the display text and may be empty.
The list endpoint returns the same objects without options and
project_ids — follow up with a single read when you need the relations.
Every value response wraps a native value with its full definition (list-item
form plus options and project_ids), so a client can render without a
second call:
{
"value": 8,
"field": {
"id": 1,
"name": "Estimate",
"type": "decimal",
"description": "Effort estimate",
"field_config": {"required": false, "default": "", "is_api_only": false, "min": 0, "max": 100},
"display_order": 1,
"options": [],
"project_ids": []
}
}| Method | Path | Auth | Permission |
|---|---|---|---|
| GET | /management-access |
yes | any authenticated user |
| GET | /health |
yes | any authenticated user |
| GET | /definitions |
yes | manager |
| POST | /definitions |
yes | manager |
| GET | /definitions/{id} |
yes | manager |
| PUT | /definitions/{id} |
yes | manager |
| DELETE | /definitions/{id} |
yes | manager |
| POST | /definitions/{id}/impact |
yes | manager |
| GET | /definitions/{id}/impact |
yes | manager |
| GET | /tasks/{task}/custom-fields |
yes | task read |
| POST | /tasks/{task}/custom-fields |
yes | task write |
| GET | /tasks/{task}/custom-fields/{field_id} |
yes | task read |
| POST | /tasks/{task}/custom-fields/{field_id} |
yes | task write |
| PUT | /tasks/{task}/custom-fields/{field_id} |
yes | task write |
| DELETE | /tasks/{task}/custom-fields/{field_id} |
yes | task write |
| GET | /ui and /ui/app.js |
none | serves the management UI |
"Auth: yes" means any authenticated credential — a user JWT, or an API token
with the matching plugins permission (see Authentication).
Answers whether the current user is on the management whitelist. Returns
200 for any authenticated user — this is the gate check, not a gated
operation:
{"is_manager": false}Load and liveness proof:
{"name": "custom-fields", "version": "0.1.0", "status": "ok"}version is the plugin's internal version string.
List definitions, ordered by display_order ascending, in list-item form
(no options/project_ids). Requires the management whitelist — regular
users get their fields embedded in task value reads instead.
| Query parameter | Type | Meaning |
|---|---|---|
project_id |
int | Only fields assigned to that project (or global). Omit for all fields. |
curl -s -H "Authorization: Bearer $JWT" \
"$BASE/definitions?project_id=3"[
{
"id": 1,
"name": "Estimate",
"type": "decimal",
"description": "Effort estimate",
"field_config": {"required": false, "default": "", "is_api_only": false, "min": 0, "max": 100},
"display_order": 1
}
]Errors: 403 (not a manager), 400 invalid project_id.
Create a definition. The body is the full definition; relations are created in the same transaction:
| Field | Type | Rules |
|---|---|---|
name |
string | Required, non-empty after trimming |
type |
string | One of the ten types (see table above) |
description |
string | Optional |
field_config |
object | Optional; min/max only valid for integer/decimal, min ≤ max; required, default, is_api_only stored as metadata |
display_order |
int | Optional, default 0 |
options |
array | Only for select/multiselect; value required and unique (non-empty), label optional, display_order optional |
project_ids |
array of int | Project IDs to assign; empty/omitted = global. 0 is rejected. Each ID must exist. |
curl -s -X POST -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{
"name": "Priority",
"type": "select",
"display_order": 2,
"options": [
{"value": "low", "label": "Low", "display_order": 0},
{"value": "high", "label": "High", "display_order": 1}
],
"project_ids": [3, 7]
}' \
"$BASE/definitions"Returns 201 with the single-resource form — the canonical re-read with
real option IDs and resolved project_ids. Errors: 403, and the 400
definition-validation catalog above.
Fetch one definition in single-resource form (with options and
project_ids). Errors: 403, 404.
Replace a definition wholesale. The body is the same full definition as create; every field is written as sent — fields omitted from the body are cleared. Relations are replaced in the same transaction:
optionsare reconciled by value: options whosevaluealready exists are updated in place (their IDs are preserved, which stored select values reference); new values are inserted; options absent from the body are deleted. Stored values that referenced deleted options read back without them (nullwhen nothing remains).project_idsare replaced; empty array means global.0is rejected; each ID must exist.
Returns 200 with the single-resource form of the updated definition.
Changing the type does not delete stored values — they remain stored but
read back as null until they happen to validate again (see the read-as-null
policy). Errors: 403, 404, 400 catalog.
Delete a definition. All stored values are destroyed — values, their select-option links, the option list, and the project assignments cascade synchronously in one transaction. There is no archive or undo.
Returns 204 No Content. Errors: 403, 404.
Preview how many stored values a candidate update would invalidate. The body
is exactly the body the subsequent PUT would send — the server diffs the
candidate against the current definition; clients must not reimplement the
semantics. Invalidating changes are: a type change (all values), removing
options from a select-like field (values using them), and tightening a
numeric min/max (out-of-range values):
curl -s -X POST -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{"name": "Priority", "type": "select", "options": [{"value": "low"}], "project_ids": [3]}' \
"$BASE/definitions/2/impact"{"affected_values": 4}Writes nothing. Errors: 403, 404, 400.
Preview the delete cascade — how many stored values deleting this definition would destroy (the total for the field):
{"affected_values": 12}Errors: 403, 404.
Value paths are nested under the task: /tasks/{task}/custom-fields, where
{task} is the Vikunja task ID and {field_id} is the definition ID.
All of the task's project-assigned fields with their values, as a map keyed by definition ID (as a string). This is the canonical read the fork's task detail view renders from:
- Fields assigned to the task's project but with no value (or an unreadable
one) appear with
value: null— assigned-but-unset is present. - Fields not assigned to the task's project are absent entirely.
- Ordered content is not guaranteed by the map; each entry's
field.display_orderis the sort key.
curl -s -H "Authorization: Bearer $JWT" \
"$BASE/tasks/42/custom-fields"{
"1": {"value": 8, "field": {"id": 1, "name": "Estimate", "type": "decimal", "field_config": {"required": false, "default": "", "is_api_only": false, "min": 0, "max": 100}, "display_order": 1, "options": [], "project_ids": []}},
"2": {"value": ["bug", "regression"], "field": {"id": 2, "name": "Tags", "type": "multiselect", "field_config": {"required": false, "default": "", "is_api_only": false}, "display_order": 2, "options": [{"id": 4, "custom_field_definition_id": 2, "value": "bug", "label": "Bug", "display_order": 0}, {"id": 5, "custom_field_definition_id": 2, "value": "regression", "label": "Regression", "display_order": 1}], "project_ids": []}},
"3": {"value": null, "field": {"id": 3, "name": "Reviewed", "type": "checkbox", "field_config": {"required": false, "default": "", "is_api_only": false}, "display_order": 3, "options": [], "project_ids": []}}
}Errors: 403 no access to this task — no read permission, or the task does
not exist (a missing task fails the permission check, not a lookup).
Write one or more values in a single atomic request. The body is a bare JSON array — no wrapper object. Each item targets a field by ID; each write is an upsert (an existing value for that field on that task is replaced):
[
{"custom_field_definition_id": 1, "value": 13},
{"custom_field_definition_id": 2, "value": ["bug"]},
{"custom_field_definition_id": 3, "value": true}
]Rules:
- Every item must pass validation for its field's type and the field must apply to the task's project — otherwise the whole batch fails and nothing is written (single transaction).
- Unknown field IDs fail the batch with
404. - The write uses the same coercion as single writes: writing a value that validates is equivalent to the per-field PUT.
Returns 200 with the full task map (same shape as the collection GET),
re-read after the commit. Errors: 403 no write access to this task (also for
a task that does not exist), 404 (unknown field ID), 400 (validation,
first failing item).
One field's entry on the task, in single-entry form:
{"value": ["bug"], "field": {"id": 2, "name": "Tags", "type": "multiselect", "description": "", "field_config": {"required": false, "default": "", "is_api_only": false}, "display_order": 2, "options": [{"id": 4, "custom_field_definition_id": 2, "value": "bug", "label": "Bug", "display_order": 0}], "project_ids": []}}An assigned-but-unset field returns 200 with value: null. 404 value not found means the field is not assigned to the task's project (or the
definition no longer exists). Errors: 403 no access to this task.
Create a value for one field. Body:
{"value": ["bug", "regression"]}Create-only: if a value already exists for this (field, task) pair the request
fails with 409 — use PUT to overwrite. Returns 201 with the single entry,
re-read after the commit. Errors: 403 no write access to this task, 404,
400 (validation, including field is not assigned to this task's project).
Replace one field's value. Body as for POST. Replace-only: fails with 404
(custom field value not found for field <id> on task <id>) when no value
exists yet — use POST to create. Returns 200 with the single entry,
re-read after the commit. Errors: 403, 404, 400.
Delete one field's value (and, for select-like fields, its stored option
links). Idempotent: 204 No Content whether or not a value existed. Errors:
403 no write access to this task.
- Cascades. Deleting a definition destroys all its stored values
immediately. Deleting a task in Vikunja destroys the task's custom field
values asynchronously via the host's
task.deletedevent — a value read in the window between task deletion and listener completion finds nothing. - No events for value changes. Writes are not broadcast; poll the read endpoints if you need to track changes.
- Empty bodies. A definition list with no fields returns
[]; a task with no assigned fields returns{}. - Bulk is atomic; single writes are not. The bulk POST wraps all items in one transaction; per-field writes commit individually.
- Select values are option-ID links. Option IDs are preserved across label/display-order edits, so relabeling an option re-labels existing values. Deleting an option removes it from every stored value that used it.
Not part of the JSON API, listed for completeness. Both are unauthenticated
(the page authenticates its own API calls) and served from the plugin's
ui/ directory on disk — edits take effect on the next page load:
| Path | Response |
|---|---|
GET /api/v1/plugins/custom-fields/ui |
index.html (text/html) |
GET /api/v1/plugins/custom-fields/ui/app.js |
app.js (text/javascript) |
BASE=http://localhost:3456/api/v1/plugins/custom-fields
JWT=<a Vikunja login JWT for a whitelisted user>
# 1. Create a select field on project 3
curl -s -X POST -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{"name": "Severity", "type": "select",
"options": [{"value": "s1", "label": "Critical"}, {"value": "s2", "label": "Major"}],
"project_ids": [3]}' \
"$BASE/definitions" | jq .
# 2. Set a value on task 42 (bulk endpoint, bare array)
curl -s -X POST -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '[{"custom_field_definition_id": 1, "value": "s1"}]' \
"$BASE/tasks/42/custom-fields" | jq .
# 3. Read it back
curl -s -H "Authorization: Bearer $JWT" "$BASE/tasks/42/custom-fields" | jq .
# 4. Clear it
curl -s -X DELETE -H "Authorization: Bearer $JWT" \
"$BASE/tasks/42/custom-fields/1"A non-manager JWT can do everything in steps 2–4 (gated only by task
permissions) but gets 403 on the definition endpoints.