diff --git a/CRM/I3val/Handler/ContactCustomUpdate.php b/CRM/I3val/Handler/ContactCustomUpdate.php
new file mode 100644
index 0000000..91e1810
--- /dev/null
+++ b/CRM/I3val/Handler/ContactCustomUpdate.php
@@ -0,0 +1,375 @@
+<?php
+/*-------------------------------------------------------+
+| Ilja's Input Validation Extension                      |
+| Amnesty International Vlaanderen                       |
+| Copyright (C) 2019 SYSTOPIA                            |
+| Author: B. Endres (endres@systopia.de)                 |
+| http://www.systopia.de/                                |
++--------------------------------------------------------+
+| This program is released as free software under the    |
+| Affero GPL license. You can redistribute it and/or     |
+| modify it under the terms of this license which you    |
+| can read by viewing the included agpl.txt or online    |
+| at www.gnu.org/licenses/agpl.html. Removal of this     |
+| copyright header is strictly prohibited without        |
+| written permission from the original author(s).        |
++--------------------------------------------------------*/
+
+use CRM_I3val_ExtensionUtil as E;
+
+/**
+ * this class will handle performing the changes
+ *  that are passed on from the API call
+ */
+class CRM_I3val_Handler_ContactCustomUpdate extends CRM_I3val_ActivityHandler {
+
+  public static $group_name = 'i3val_contact_custom_updates';
+  public static $field2label = NULL;
+
+  public function getField2Label() {
+    if (self::$field2label === NULL) {
+      self::$field2label = ['value' => E::ts('Value')];
+    }
+    return self::$field2label;
+  }
+
+  /**
+   * get the main key/identifier for this handler
+   */
+  public function getKey() {
+    return 'contact';
+  }
+
+  /**
+   * get a human readable name for this handler
+   */
+  public function getName() {
+    return E::ts("Contact Custom Fields Update");
+  }
+
+  /**
+   * returns a list of CiviCRM entities this handler can process
+   */
+  public function handlesEntities() {
+    return array('Contact');
+  }
+
+  /**
+   * get the list of
+   */
+  public function getFields() {
+    $field2label = $this->getField2Label();
+    return array_keys($field2label);
+  }
+
+  /**
+   * Get the JSON specification file defining the custom group used for this data
+   */
+  public function getCustomGroupSpeficationFiles() {
+    return array(__DIR__ . '/../../../resources/contact_custom_updates_custom_group.json');
+  }
+
+  /**
+   * Get the custom group name
+   */
+  public function getCustomGroupName() {
+    return self::$group_name;
+  }
+
+  /**
+   * Verify whether the changes make sense
+   *
+   * @return array $key -> error message
+   */
+  public function verifyChanges($activity, $values, $objects = array()) {
+    // TODO: check?
+    return array();
+  }
+
+  /**
+   * Apply the changes
+   *
+   * @return array with changes to the activity
+   */
+  public function applyChanges($activity, $values, $objects = array()) {
+    $contact = $objects['contact'];
+    $activity_update = array();
+    if (!$this->hasData($activity)) {
+      // NO DATA, no updates
+      return $activity_update;
+    }
+
+    // calculate generic update
+    $contact_update = array();
+    $this->applyUpdateData($contact_update, $values, '%s', "%s_applied");
+
+    // remove the ones that are not flagged as 'apply'
+    foreach (array_keys($contact_update) as $key) {
+      $apply_key = "{$key}_apply";
+      if (!isset($values[$apply_key]) || !strlen($values[$apply_key])) {
+        unset($contact_update[$key]);
+      }
+    }
+    $this->applyUpdateData($activity_update, $contact_update, self::$group_name . '.%s_applied', '%s');
+
+    // execute update
+    if (!empty($contact_update)) {
+      $contact_update['id'] = $contact['id'];
+      $this->resolveFields($contact_update);
+      $this->resolvePreferredLanguageToLabel($contact_update, FALSE);
+      CRM_I3val_Session::log("UPDATE contact " . json_encode($contact_update));
+      civicrm_api3('Contact', 'create', $contact_update);
+    }
+
+    return $activity_update;
+  }
+
+
+  /**
+   * Load and assign necessary data to the form
+   */
+  public function renderActivityData($activity, $form) {
+    $config = CRM_I3val_Configuration::getConfiguration();
+    $field2label = self::getField2Label();
+    $values = $this->compileValues(self::$group_name, $field2label, $activity);
+    $this->resolvePreferredLanguageToLabel($form->contact);
+    $this->addCurrentValues($values, $form->contact);
+
+    // exceptions for current values
+    if (isset($values['prefix']) && !empty($form->contact['individual_prefix'])) {
+      $values['prefix']['current'] = $form->contact['individual_prefix'];
+    }
+    if (isset($values['suffix']) && !empty($form->contact['individual_suffix'])) {
+      $values['suffix']['current'] = $form->contact['individual_suffix'];
+    }
+
+    // create input fields and apply checkboxes
+    $active_fields = array();
+    $checkbox_fields = array(); // these will be displayed as checkboxes rather than strings
+
+    foreach ($field2label as $fieldname => $fieldlabel) {
+      // if there is no values, omit field
+      if ($config->clearingFieldsAllowed()) {
+        if (empty($values[$fieldname]['submitted']) && empty($values[$fieldname]['original'])) {
+          continue;
+        }
+      } else {
+        if (!isset($values[$fieldname]['submitted']) || !strlen($values[$fieldname]['submitted'])) {
+          continue;
+        }
+      }
+
+      // this field has data:
+      $active_fields[$fieldname] = $fieldlabel;
+
+      // generate input field
+      if (in_array($fieldname, array('prefix', 'suffix', 'gender'))) {
+        // add the text input
+        $form->add(
+          'select',
+          "{$fieldname}_applied",
+          $fieldlabel,
+          $this->getOptionList($fieldname)
+        );
+
+      } elseif ($fieldname == 'birth_date' || $fieldname == 'deceased_date') {
+        $form->addDate(
+          "{$fieldname}_applied",
+          $fieldlabel,
+          FALSE,
+          array('formatType' => 'activityDate')
+        );
+
+        // format date (drop time)
+        if (isset($values[$fieldname]['submitted'])) {
+          $values[$fieldname]['submitted'] = substr($values[$fieldname]['submitted'], 0, 10);
+        }
+        if (isset($values[$fieldname]['original'])) {
+          $values[$fieldname]['original'] = substr($values[$fieldname]['original'], 0, 10);
+        }
+
+      } elseif ($fieldname == 'preferred_language') {
+        $form->add(
+          'select',
+          "{$fieldname}_applied",
+          $fieldlabel,
+          $this->getOptionValueList('languages', 'label', E::ts("none"))
+        );
+
+
+
+      } elseif (substr($fieldname, 0, 3) == 'do_' || substr($fieldname, 0, 3) == 'is_') {
+        $checkbox_fields[$fieldname] = 1;
+        $form->add(
+          'checkbox',
+          "{$fieldname}_applied",
+          $fieldlabel
+        );
+
+      } else {
+        // add the text input
+        $form->add(
+          'text',
+          "{$fieldname}_applied",
+          $fieldlabel
+        );
+      }
+
+      if (!empty($values[$fieldname]['applied'])) {
+        $form->setDefaults(array("{$fieldname}_applied" => $values[$fieldname]['applied']));
+      } else {
+        $form->setDefaults(array("{$fieldname}_applied" => isset($values[$fieldname]['submitted']) ? $values[$fieldname]['submitted'] : ''));
+      }
+
+      // add the apply checkbox
+      $form->add(
+        'checkbox',
+        "{$fieldname}_apply",
+        $fieldlabel
+      );
+      $form->setDefaults(array("{$fieldname}_apply" => 1));
+    }
+
+    $form->assign('i3val_contact_fields', $field2label);
+    $form->assign('i3val_contact_values', $values);
+    $form->assign('i3val_active_contact_fields', $active_fields);
+    $form->assign('i3val_active_contact_checkboxes', $checkbox_fields);
+  }
+
+  /**
+   * Get the path of the template rendering the form
+   */
+  public function getTemplate() {
+    return 'CRM/I3val/Handler/ContactUpdate.tpl';
+  }
+
+  /**
+   * Calculate the data to be created and add it to the $activity_data Activity.create params
+   * @todo specify
+   */
+  public function generateDiffData($entity, $submitted_data, &$activity_data) {
+    if ($entity != 'Contact') {
+      throw new Exception("Can only process Contact entity");
+    }
+
+    // make sure the custom fields are in the 'custom_xx' format
+    CRM_I3val_CustomData::resolveCustomFields($submitted_data);
+
+    // get all custom fields
+    $submitted_custom_data = [];
+    $custom_field_ids = [];
+    foreach ($submitted_data as $field_name => $field_data) {
+      if (preg_match('/^custom_(?<custom_field_id>[0-9]+)$/', $field_name, $match)) {
+        $submitted_custom_data[$field_name] = $field_data;
+        $custom_field_ids[] = $match['custom_field_id'];
+      }
+    }
+
+    if (!empty($submitted_custom_data)) {
+      // load that custom data
+      $current_custom_data = [];
+      $custom_data_query = civicrm_api3('CustomValue', 'get', [
+          'option.limit' => 0,
+          'entity_id'    => $submitted_data['id'],
+          'entity_table' => 'civicrm_contact']);
+      foreach ($custom_data_query['values'] as $field_id => $field_data) {
+        $current_custom_data["custom_{$field_id}"] = $field_data['latest'];
+      }
+
+      // resolve / diff
+      $change_index = 1;
+      foreach ($custom_field_ids as $field_id) {
+        $submitted_value = CRM_Utils_Array::value("custom_{$field_id}", $submitted_custom_data, '');
+        $current_value   = CRM_Utils_Array::value("custom_{$field_id}", $current_custom_data, '');
+        if ($this->differs($field_id, $submitted_value, $current_value)) {
+          // there is a difference -> add a record
+          $custom_id_field = CRM_I3val_CustomData::getCustomFieldKey(self::$group_name, 'custom_field_id');
+          $original_field  = CRM_I3val_CustomData::getCustomFieldKey(self::$group_name, 'value_original');
+          $submitted_field = CRM_I3val_CustomData::getCustomFieldKey(self::$group_name, 'value_submitted');
+          $activity_data["{$custom_id_field}:-{$change_index}"] = $field_id;
+          $activity_data["{$original_field}:-{$change_index}"]  = $current_value;
+          $activity_data["{$submitted_field}:-{$change_index}"] = $submitted_value;
+          $change_index++;
+        }
+      }
+    }
+  }
+
+  /**
+   * Check whether the presented values differ in the context of the given custom field
+   *
+   * @param $field_id integer custom field id
+   * @param $value1   mixed   value 1
+   * @param $value2   mixed   value 2
+   * @return boolean
+   */
+  protected function differs($field_id, $value1, $value2) {
+    // todo: implement
+    return $value1 != $value2;
+  }
+
+  /**
+   * Resolve the text field names (e.g. 'gender')
+   *  to their ID representations ('gender_id').
+   */
+  protected function resolveFields(&$data, $add_default = FALSE) {
+    parent::resolveFields($data, $add_default);
+    $this->resolveOptionValueField($data, 'gender', 'gender', 'gender_id');
+    $this->resolveOptionValueField($data, 'individual_prefix', 'prefix', 'prefix_id');
+    $this->resolveOptionValueField($data, 'individual_suffix', 'suffix', 'suffix_id');
+  }
+
+
+  /**
+   * Get dropdown lists
+   */
+  protected function getOptionList($fieldname) {
+    $option_group_name = NULL;
+
+    switch ($fieldname) {
+      case 'gender':
+        return $this->getOptionValueList('gender', 'label', E::ts('none'));
+
+      case 'prefix':
+        return $this->getOptionValueList('individual_prefix', 'label', E::ts('none'));
+
+      case 'suffix':
+        return $this->getOptionValueList('individual_suffix', 'label', E::ts('none'));
+
+      default:
+        return $this->getOptionValueList($fieldname);
+    }
+  }
+
+  /**
+   * Since the brilliant preferred_language field has no *_id
+   * counterpart, we are forced to decide whether we want to
+   * store the key or the label. The API needs the ID, while
+   * our activities will store the label
+   */
+  protected function resolvePreferredLanguageToLabel(&$data, $to_label = TRUE) {
+    if (!empty($data['preferred_language'])) {
+      if ($to_label) {
+        // we want to make sure that the label is used...
+        if (preg_match("#^[a-z]{2}_[A-Z]{2}$#", $data['preferred_language'])) {
+          // ... but it is the key -> find the right label
+          $languages = $this->getOptionValues('languages', 'name');
+          if (isset($languages[$data['preferred_language']])) {
+            $data['preferred_language'] = $languages[$data['preferred_language']]['label'];
+          } else {
+            // not found
+            unset($data['preferred_language']);
+          }
+        }
+
+      } else {
+        // we want to make sure that the key is there...
+        if (!preg_match("#^[a-z]{2}_[A-Z]{2}$#", $data['preferred_language'])) {
+          // ...but it seems like it isn't -> find best match
+          $option_value = $this->getMatchingOptionValue('languages', $data['preferred_language'], TRUE, 'name');
+          $data['preferred_language'] = $option_value['name'];
+        }
+      }
+    }
+  }
+}
diff --git a/CRM/I3val/Logic.php b/CRM/I3val/Logic.php
index b61c80b..b6d8081 100644
--- a/CRM/I3val/Logic.php
+++ b/CRM/I3val/Logic.php
@@ -65,15 +65,46 @@ public static function createEntityUpdateRequest($entity, $params) {
       $activity_data['activity_type_id'] = $activity_type_id;
 
       // create activity, reload and return
-      CRM_I3val_Session::log('ACTIVIY ' . json_encode($activity_data));
-      CRM_I3val_CustomData::resolveCustomFields($activity_data);
-      $activity = civicrm_api3('Activity', 'create', $activity_data);
-      return civicrm_api3('Activity', 'getsingle', array('id' => $activity['id']));
+      return self::createActivity($activity_data);
     } else {
       return NULL;
     }
   }
 
+  /**
+   * Create a new activity, along with the custom fields:
+   *
+   * @param $activity_data  array activity data
+   * @return array created activity data
+=   */
+  protected static function createActivity($activity_data) {
+    //CRM_I3val_Session::log('ACTIVIY ' . json_encode($activity_data));
+
+    // first: resolve the (remaining) parameters to the custom_xx notation
+    CRM_I3val_CustomData::resolveCustomFields($activity_data);
+
+    // separate out the parameters
+    $activity_base_data = [];
+    $activity_custom_data = [];
+    foreach ($activity_data as $field_name => $field_value) {
+      if (preg_match("/^custom_[0-9]/", $field_name)) {
+        $activity_custom_data[$field_name] = $field_value;
+      } else {
+        $activity_base_data[$field_name] = $field_value;
+      }
+    }
+
+    // create the activity itself
+    $activity = civicrm_api3('Activity', 'create', $activity_base_data);
+
+    // create the custom data
+    if (!empty($activity_custom_data)) {
+      $activity_custom_data['entity_id'] = $activity['id'];
+      civicrm_api3('CustomValue', 'create', $activity_custom_data);
+    }
+
+    return civicrm_api3('Activity', 'getsingle', ['id' => $activity['id']]);
+  }
 
   /**
    * Add the generic activity parameters, partly derived from the $params
diff --git a/resources/contact_custom_updates_custom_group.json b/resources/contact_custom_updates_custom_group.json
new file mode 100644
index 0000000..5fd23be
--- /dev/null
+++ b/resources/contact_custom_updates_custom_group.json
@@ -0,0 +1,75 @@
+{
+  "_lookup": ["name"],
+  "_translate": ["title"],
+  "name": "i3val_contact_custom_updates",
+  "title": "Contact Custom Fields Update",
+  "extends": "Activity",
+  "style": "Inline",
+  "collapse_display": "1",
+  "is_active": "1",
+  "is_multiple": "1",
+  "table_name": "civicrm_value_i3val_contact_custom_updates",
+  "collapse_adv_display": "1",
+  "is_reserved": "0",
+  "_fields": [
+    {
+      "_lookup": ["column_name", "custom_group_id"],
+      "_translate": ["label"],
+      "name": "custom_field_id",
+      "column_name": "custom_field_id",
+      "label": "Custom Field ID",
+      "data_type": "Integer",
+      "html_type": "Text",
+      "is_required": "0",
+      "is_searchable": "1",
+      "is_search_range": "1",
+      "is_active": "1",
+      "is_view": "1"
+    },
+    {
+      "_lookup": ["column_name", "custom_group_id"],
+      "_translate": ["label"],
+      "name": "value_original",
+      "column_name": "value_original",
+      "label": "Value (original)",
+      "data_type": "String",
+      "html_type": "Text",
+      "text_length": "255",
+      "is_required": "0",
+      "is_searchable": "1",
+      "is_search_range": "1",
+      "is_active": "1",
+      "is_view": "1"
+    },
+    {
+      "_lookup": ["column_name", "custom_group_id"],
+      "_translate": ["label"],
+      "name": "value_submitted",
+      "column_name": "value_submitted",
+      "label": "Value (submitted)",
+      "data_type": "String",
+      "html_type": "Text",
+      "text_length": "255",
+      "is_required": "0",
+      "is_searchable": "1",
+      "is_search_range": "1",
+      "is_active": "1",
+      "is_view": "1"
+    },
+    {
+      "_lookup": ["column_name", "custom_group_id"],
+      "_translate": ["label"],
+      "name": "value_applied",
+      "column_name": "value_applied",
+      "label": "Value (applied)",
+      "data_type": "String",
+      "html_type": "Text",
+      "text_length": "255",
+      "is_required": "0",
+      "is_searchable": "1",
+      "is_search_range": "1",
+      "is_active": "1",
+      "is_view": "1"
+    }
+  ]
+}
\ No newline at end of file