diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala b/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala
index 70bd0f49..4792a4d9 100644
--- a/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala
+++ b/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala
@@ -1,10 +1,15 @@
 package docspell.backend.ops
 
+import cats.data.EitherT
 import cats.data.OptionT
 import cats.effect._
+import cats.implicits._
 
 import docspell.backend.ops.OCustomFields.CustomFieldData
 import docspell.backend.ops.OCustomFields.NewCustomField
+import docspell.backend.ops.OCustomFields.RemoveValue
+import docspell.backend.ops.OCustomFields.SetValue
+import docspell.backend.ops.OCustomFields.SetValueResult
 import docspell.common._
 import docspell.store.AddResult
 import docspell.store.Store
@@ -12,20 +17,32 @@ import docspell.store.UpdateResult
 import docspell.store.queries.QCustomField
 import docspell.store.records.RCustomField
 import docspell.store.records.RCustomFieldValue
+import docspell.store.records.RItem
 
 import doobie._
 
 trait OCustomFields[F[_]] {
 
+  /** Find all fields using an optional query on the name and label */
   def findAll(coll: Ident, nameQuery: Option[String]): F[Vector[CustomFieldData]]
 
+  /** Find one field by its id */
   def findById(coll: Ident, fieldId: Ident): F[Option[CustomFieldData]]
 
+  /** Create a new non-existing field. */
   def create(field: NewCustomField): F[AddResult]
 
+  /** Change an existing field. */
   def change(field: RCustomField): F[UpdateResult]
 
+  /** Deletes the field by name or id. */
   def delete(coll: Ident, fieldIdOrName: Ident): F[UpdateResult]
+
+  /** Sets a value given a field an an item. Existing values are overwritten. */
+  def setValue(item: Ident, value: SetValue): F[SetValueResult]
+
+  /** Deletes a value for a given field an item. */
+  def deleteValue(in: RemoveValue): F[UpdateResult]
 }
 
 object OCustomFields {
@@ -40,6 +57,32 @@ object OCustomFields {
       cid: Ident
   )
 
+  case class SetValue(
+      field: Ident,
+      value: String,
+      collective: Ident
+  )
+
+  sealed trait SetValueResult
+  object SetValueResult {
+
+    case object ItemNotFound             extends SetValueResult
+    case object FieldNotFound            extends SetValueResult
+    case class ValueInvalid(msg: String) extends SetValueResult
+    case object Success                  extends SetValueResult
+
+    def itemNotFound: SetValueResult              = ItemNotFound
+    def fieldNotFound: SetValueResult             = FieldNotFound
+    def valueInvalid(msg: String): SetValueResult = ValueInvalid(msg)
+    def success: SetValueResult                   = Success
+  }
+
+  case class RemoveValue(
+      field: Ident,
+      item: Ident,
+      collective: Ident
+  )
+
   def apply[F[_]: Effect](
       store: Store[F]
   ): Resource[F, OCustomFields[F]] =
@@ -76,5 +119,39 @@ object OCustomFields {
 
         UpdateResult.fromUpdate(store.transact(update.getOrElse(0)))
       }
+
+      def setValue(item: Ident, value: SetValue): F[SetValueResult] =
+        (for {
+          field <- EitherT.fromOptionF(
+            store.transact(RCustomField.findByIdOrName(value.field, value.collective)),
+            SetValueResult.fieldNotFound
+          )
+          _ <- EitherT(
+            store
+              .transact(RItem.existsByIdAndCollective(item, value.collective))
+              .map(flag => if (flag) Right(()) else Left(SetValueResult.itemNotFound))
+          )
+          fval <- EitherT.fromEither[F](
+            field.ftype
+              .parseValue(value.value)
+              .leftMap(SetValueResult.valueInvalid)
+              .map(field.ftype.valueString)
+          )
+          nu <- EitherT.right[SetValueResult](
+            store.transact(RCustomField.setValue(field, item, fval))
+          )
+        } yield nu).fold(identity, _ => SetValueResult.success)
+
+      def deleteValue(in: RemoveValue): F[UpdateResult] = {
+        val update =
+          for {
+            field <- OptionT(RCustomField.findByIdOrName(in.field, in.collective))
+            n     <- OptionT.liftF(RCustomFieldValue.deleteValue(field.id, in.item))
+          } yield n
+
+        UpdateResult.fromUpdate(store.transact(update.getOrElse(0)))
+      }
+
     })
+
 }
diff --git a/modules/common/src/main/scala/docspell/common/CustomFieldType.scala b/modules/common/src/main/scala/docspell/common/CustomFieldType.scala
index 9ff08bbf..1505233f 100644
--- a/modules/common/src/main/scala/docspell/common/CustomFieldType.scala
+++ b/modules/common/src/main/scala/docspell/common/CustomFieldType.scala
@@ -1,25 +1,83 @@
 package docspell.common
 
+import java.time.LocalDate
+
+import cats.implicits._
+
 import io.circe._
 
 sealed trait CustomFieldType { self: Product =>
 
+  type ValueType
+
   final def name: String =
     self.productPrefix.toLowerCase()
 
+  def valueString(value: ValueType): String
+
+  def parseValue(value: String): Either[String, ValueType]
 }
 
 object CustomFieldType {
 
-  case object Text extends CustomFieldType
+  case object Text extends CustomFieldType {
 
-  case object Numeric extends CustomFieldType
+    type ValueType = String
 
-  case object Date extends CustomFieldType
+    def valueString(value: String): String =
+      value
 
-  case object Bool extends CustomFieldType
+    def parseValue(value: String): Either[String, String] =
+      Right(value)
+  }
 
-  case object Money extends CustomFieldType
+  case object Numeric extends CustomFieldType {
+    type ValueType = BigDecimal
+
+    def valueString(value: BigDecimal): String =
+      value.toString
+
+    def parseValue(value: String): Either[String, BigDecimal] =
+      Either
+        .catchNonFatal(BigDecimal.exact(value))
+        .leftMap(_ => s"Could not parse decimal value from: $value")
+  }
+
+  case object Date extends CustomFieldType {
+    type ValueType = LocalDate
+
+    def valueString(value: LocalDate): String =
+      value.toString
+
+    def parseValue(value: String): Either[String, LocalDate] =
+      Either
+        .catchNonFatal(LocalDate.parse(value))
+        .leftMap(_.getMessage)
+  }
+
+  case object Bool extends CustomFieldType {
+    type ValueType = Boolean
+
+    def valueString(value: Boolean): String =
+      value.toString
+
+    def parseValue(value: String): Either[String, Boolean] =
+      Right(value.equalsIgnoreCase("true"))
+
+  }
+
+  case object Money extends CustomFieldType {
+    type ValueType = BigDecimal
+
+    def valueString(value: BigDecimal): String =
+      Numeric.valueString(value)
+
+    def parseValue(value: String): Either[String, BigDecimal] =
+      Numeric.parseValue(value).map(round)
+
+    def round(v: BigDecimal): BigDecimal =
+      v.setScale(2, BigDecimal.RoundingMode.HALF_EVEN)
+  }
 
   def text: CustomFieldType    = Text
   def numeric: CustomFieldType = Numeric
diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml
index ea6addb8..c1a849b1 100644
--- a/modules/restapi/src/main/resources/docspell-openapi.yml
+++ b/modules/restapi/src/main/resources/docspell-openapi.yml
@@ -1914,6 +1914,50 @@ paths:
                 type: string
                 format: binary
 
+  /sec/item/{id}/customfield:
+    put:
+      tags: [ Item ]
+      summary: Set the value of a custom field.
+      description: |
+        Sets the value for a custom field to this item. If a value
+        already exists, it is overwritten.
+      security:
+        - authTokenHeader: []
+      parameters:
+        - $ref: "#/components/parameters/id"
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: "#/components/schemas/CustomFieldValue"
+      responses:
+        200:
+          description: Ok
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/BasicResult"
+  /sec/item/{itemId}/customfield/{id}:
+    delete:
+      tags: [ Item ]
+      summary: Removes the value for a custom field
+      description: |
+        Removes the value for the given custom field. The `id` may be
+        the id of a custom field or its name.
+      security:
+        - authTokenHeader: []
+      parameters:
+        - $ref: "#/components/parameters/id"
+        - $ref: "#/components/parameters/itemId"
+      responses:
+        200:
+          description: Ok
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/BasicResult"
+
+
   /sec/item/{itemId}/reprocess:
     post:
       tags: [ Item ]
@@ -3240,6 +3284,8 @@ paths:
                 $ref: "#/components/schemas/BasicResult"
 
   /sec/customfields/{id}:
+    parameters:
+      - $ref: "#/components/parameters/id"
     get:
       tags: [ Custom Fields ]
       summary: Get details about a custom field.
@@ -3385,6 +3431,19 @@ components:
           items:
             $ref: "#/components/schemas/CustomField"
 
+    CustomFieldValue:
+      description: |
+        Data structure to update the value of a custom field.
+      required:
+        - field
+        - value
+      properties:
+        field:
+          type: string
+          format: ident
+        value:
+          type: string
+
     NewCustomField:
       description: |
         Data for creating a custom field.
diff --git a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala
index 8c889f6b..2762b3ae 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala
@@ -7,6 +7,7 @@ import cats.implicits._
 import fs2.Stream
 
 import docspell.backend.ops.OCollective.{InsightData, PassChangeResult}
+import docspell.backend.ops.OCustomFields.SetValueResult
 import docspell.backend.ops.OJob.JobCancelResult
 import docspell.backend.ops.OUpload.{UploadData, UploadMeta, UploadResult}
 import docspell.backend.ops._
@@ -589,6 +590,18 @@ trait Conversions {
 
   // basic result
 
+  def basicResult(r: SetValueResult): BasicResult =
+    r match {
+      case SetValueResult.FieldNotFound =>
+        BasicResult(false, "The given field is unknown")
+      case SetValueResult.ItemNotFound =>
+        BasicResult(false, "The given item is unknown")
+      case SetValueResult.ValueInvalid(msg) =>
+        BasicResult(false, s"The value is invalid: $msg")
+      case SetValueResult.Success =>
+        BasicResult(true, "Custom field value set successfully.")
+    }
+
   def basicResult(cr: JobCancelResult): BasicResult =
     cr match {
       case JobCancelResult.JobNotFound => BasicResult(false, "Job not found")
diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala
index ba0c8c08..8f25eb08 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala
@@ -6,6 +6,7 @@ import cats.implicits._
 
 import docspell.backend.BackendApp
 import docspell.backend.auth.AuthToken
+import docspell.backend.ops.OCustomFields.{RemoveValue, SetValue}
 import docspell.backend.ops.OFulltext
 import docspell.backend.ops.OItemSearch.Batch
 import docspell.common.syntax.all._
@@ -358,6 +359,24 @@ object ItemRoutes {
           resp <- Ok(Conversions.basicResult(res, "Re-process task submitted."))
         } yield resp
 
+      case req @ PUT -> Root / Ident(id) / "customfield" =>
+        for {
+          data <- req.as[CustomFieldValue]
+          res <- backend.customFields.setValue(
+            id,
+            SetValue(data.field, data.value, user.account.collective)
+          )
+          resp <- Ok(Conversions.basicResult(res))
+        } yield resp
+
+      case DELETE -> Root / Ident(id) / "customfield" / Ident(fieldId) =>
+        for {
+          res <- backend.customFields.deleteValue(
+            RemoveValue(fieldId, id, user.account.collective)
+          )
+          resp <- Ok(Conversions.basicResult(res, "Custom field value removed."))
+        } yield resp
+
       case DELETE -> Root / Ident(id) =>
         for {
           n <- backend.item.deleteItem(id, user.account.collective)
diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.13.0__custom_fields.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.13.0__custom_fields.sql
index 4a18bcaa..708989bf 100644
--- a/modules/store/src/main/resources/db/migration/postgresql/V1.13.0__custom_fields.sql
+++ b/modules/store/src/main/resources/db/migration/postgresql/V1.13.0__custom_fields.sql
@@ -13,8 +13,7 @@ CREATE TABLE "custom_field_value" (
   "id" varchar(254) not null primary key,
   "item_id" varchar(254) not null,
   "field" varchar(254) not null,
-  "value_text" varchar(300),
-  "value_numeric" numeric,
+  "field_value" varchar(300) not null,
   foreign key ("item_id") references "item"("itemid"),
   foreign key ("field") references "custom_field"("id"),
   unique ("item_id", "field")
diff --git a/modules/store/src/main/scala/docspell/store/records/RCustomField.scala b/modules/store/src/main/scala/docspell/store/records/RCustomField.scala
index 497506e7..f74c7cc3 100644
--- a/modules/store/src/main/scala/docspell/store/records/RCustomField.scala
+++ b/modules/store/src/main/scala/docspell/store/records/RCustomField.scala
@@ -1,5 +1,7 @@
 package docspell.store.records
 
+import cats.implicits._
+
 import docspell.common._
 import docspell.store.impl.Column
 import docspell.store.impl.Implicits._
@@ -43,7 +45,7 @@ object RCustomField {
   }
 
   def exists(fname: Ident, coll: Ident): ConnectionIO[Boolean] =
-    ???
+    selectCount(id, table, and(name.is(fname), cid.is(coll))).query[Int].unique.map(_ > 0)
 
   def findById(fid: Ident, coll: Ident): ConnectionIO[Option[RCustomField]] =
     selectSimple(all, table, and(id.is(fid), cid.is(coll))).query[RCustomField].option
@@ -69,4 +71,19 @@ object RCustomField {
         ftype.setTo(value.ftype)
       )
     ).update.run
+
+  def setValue(f: RCustomField, item: Ident, fval: String): ConnectionIO[Int] =
+    for {
+      n <- RCustomFieldValue.updateValue(f.id, item, fval)
+      k <-
+        if (n == 0)
+          Ident
+            .randomId[ConnectionIO]
+            .flatMap(nId =>
+              RCustomFieldValue
+                .insert(RCustomFieldValue(nId, item, f.id, fval))
+            )
+        else 0.pure[ConnectionIO]
+    } yield n + k
+
 }
diff --git a/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala b/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala
index 85c7f58c..7789143c 100644
--- a/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala
+++ b/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala
@@ -11,8 +11,7 @@ case class RCustomFieldValue(
     id: Ident,
     itemId: Ident,
     field: Ident,
-    valueText: Option[String],
-    valueNumeric: Option[BigDecimal]
+    value: String
 )
 
 object RCustomFieldValue {
@@ -21,24 +20,34 @@ object RCustomFieldValue {
 
   object Columns {
 
-    val id           = Column("id")
-    val itemId       = Column("item_id")
-    val field        = Column("field")
-    val valueText    = Column("value_text")
-    val valueNumeric = Column("value_numeric")
+    val id     = Column("id")
+    val itemId = Column("item_id")
+    val field  = Column("field")
+    val value  = Column("field_value")
 
-    val all = List(id, itemId, field, valueText, valueNumeric)
+    val all = List(id, itemId, field, value)
   }
 
   def insert(value: RCustomFieldValue): ConnectionIO[Int] = {
     val sql = insertRow(
       table,
       Columns.all,
-      fr"${value.id},${value.itemId},${value.field},${value.valueText},${value.valueNumeric}"
+      fr"${value.id},${value.itemId},${value.field},${value.value}"
     )
     sql.update.run
   }
 
+  def updateValue(
+      fieldId: Ident,
+      item: Ident,
+      value: String
+  ): ConnectionIO[Int] =
+    updateRow(
+      table,
+      and(Columns.itemId.is(item), Columns.field.is(fieldId)),
+      Columns.value.setTo(value)
+    ).update.run
+
   def countField(fieldId: Ident): ConnectionIO[Int] =
     selectCount(Columns.id, table, Columns.field.is(fieldId)).query[Int].unique
 
@@ -47,4 +56,7 @@ object RCustomFieldValue {
 
   def deleteByItem(item: Ident): ConnectionIO[Int] =
     deleteFrom(table, Columns.itemId.is(item)).update.run
+
+  def deleteValue(fieldId: Ident, item: Ident): ConnectionIO[Int] =
+    deleteFrom(table, and(Columns.id.is(fieldId), Columns.itemId.is(item))).update.run
 }
diff --git a/modules/store/src/main/scala/docspell/store/records/RItem.scala b/modules/store/src/main/scala/docspell/store/records/RItem.scala
index a023e136..89f78e2a 100644
--- a/modules/store/src/main/scala/docspell/store/records/RItem.scala
+++ b/modules/store/src/main/scala/docspell/store/records/RItem.scala
@@ -323,6 +323,9 @@ object RItem {
   def existsById(itemId: Ident): ConnectionIO[Boolean] =
     selectCount(id, table, id.is(itemId)).query[Int].unique.map(_ > 0)
 
+  def existsByIdAndCollective(itemId: Ident, coll: Ident): ConnectionIO[Boolean] =
+    selectCount(id, table, and(id.is(itemId), cid.is(coll))).query[Int].unique.map(_ > 0)
+
   def findByIdAndCollective(itemId: Ident, coll: Ident): ConnectionIO[Option[RItem]] =
     selectSimple(all, table, and(id.is(itemId), cid.is(coll))).query[RItem].option