diff --git a/webform.api.php b/webform.api.php
index b37496e..e71e990 100644
--- a/webform.api.php
+++ b/webform.api.php
@@ -216,6 +216,103 @@ function hook_webform_submission_render_alter(&$renderable) {
 }
 
 /**
+ * Provide keys & replacement values for webform placeholder tokens.
+ *
+ * The calling function will cache tokens per node/submission/email ID.
+ * This means in practice:
+ * - when showing a webform for the user to fill out, this hook will be called
+ *   once, with all arguments NULL.
+ * - when submitting a webform, this hook will be called twice, plus once for
+ *   every e-mail, with node / submission / e-mail arguments set to
+ *   - NULL / NULL       / NULL
+ *   - node / submission / NULL
+ *   - node / submission / e-mail, for every e-mail sent
+ *   (not necessarily in this order).
+ * So if you use expensive operations to calculate token values, which are not
+ * dependent on submission e-mail values, it could be a good idea to cache them.
+ *
+ * @param $node
+ *   If replacing node-level tokens, the webform node for which tokens will be
+ *   created. It should not be modified.
+ *   One cannot assume that all content for this node has always been assembled
+ *   yet! (i.e. that  hook_nodeapi($node, 'view') has fully run.)
+ * @param $submission
+ *   If replacing submission-level tokens, the submission for which tokens will
+ *   be created. It should not be modified.
+ * @param $email
+ *   If replacing email-level tokens, the email for which tokens will be
+ *   created. It should not be modified.
+ *
+ * @return
+ *   An array containing one or two associative arrays of replacement
+ *   keys/values, with fixed keys in the outer array:
+ *   'safe' for an array of values which can always be replaced;
+ *   'unsafe' for an array of values containing potentially unsafe information.
+ *   Anything in the outer array which is keyed by something other than
+ *   'safe'/'unsafe' is ignored / reserved for (unlikely) future expansion.
+ *
+ *   If both 'safe' and 'unsafe' sub-arrays contain the same key, the value from
+ *   the 'unsafe' array is taken, unless the caller specifies that unsafe values
+ *   may not be used.
+ *   If unsafe values are not used and a corresponding key is not specified in
+ *   the 'safe' sub-array, these keys are removed from the replacement text.
+ *
+ *   The keys will be prefixed with '%' before replacement, unless they start
+ *   with '%' already. Webform's own replacement keys can be overridden.
+ */
+function hook_webform_tokens($node = NULL, $submission = NULL, $email = NULL) {
+  $replacements = array();
+
+  global $user;
+
+  if ($user->uid && module_exists('location_user')) {
+    $account = user_load($user->uid);
+    if (isset($account->location)) {
+      $replacements['unsafe']['user_location'] = theme('location', $account->location);
+    }
+  }
+  // We always provide the user_location key, so that it's removed from text if
+  // $account->location is unset. Since 'unsafe' overrides 'safe', we can:
+  // - fill default value in unsafe token if it's not filled yet;
+  // - always fill default value in safe token. Both things have equal effect.
+  $replacements['safe']['user_location'] = '';
+
+  $replacements['safe']['date_small'] = format_date(time(), 'small');
+
+  return $replacements;
+}
+
+/**
+ * Provide documentation for webform replacement tokens.
+ *
+ * @param $node
+ *   If replacing node-level tokens, the node for which tokens will be created.
+ *
+ * @return
+ *   An array of renderable components.
+ *   This can just be one component which is array('#value' => HTML ),
+ *   but could contain several elements - and needs to be 'nested' like most
+ *   Drupal hooks which provide $items structures.
+ *   Any token keys present in the text must be prefixed with '%'.
+ */
+function hook_webform_token_help($node = NULL) {
+  $items = array();
+
+  $tokens = array(
+    t("@token - Current date, formatted in the website's 'small' system format.", array('@token' => '%date_small')),
+  );
+  if (module_exists('location_user')) {
+    $tokens[] = t("@token - Current user's location, formatted as HTML.", array('@token' => '%user_location'));
+  }
+
+  $items[] = array(
+    '#value' => theme('item_list', $tokens)
+  );
+
+  return $items;
+}
+
+/**
  * Modify a loaded Webform component.
  *
  * IMPORTANT: This hook does not actually exist because components are loaded
diff --git a/webform.module b/webform.module
index 7b3f51e..8cd79d6 100644
--- a/webform.module
+++ b/webform.module
@@ -2751,6 +2751,7 @@ function _webform_fetch_draft_sid($nid, $uid) {
 function _webform_filter_values($string, $node = NULL, $submission = NULL, $email = NULL, $strict = TRUE, $allow_anonymous = FALSE) {
   global $user;
   static $replacements;
+  static $ext_replacements;
 
   // Don't do any filtering if the string is empty.
   if (strlen(trim($string)) == 0) {
@@ -2882,12 +2883,59 @@ function _webform_filter_values($string, $node = NULL, $submission = NULL, $emai
     }
   }
 
+  // Get 'external' replacements and cache them by node + submission + e-mail.
+  $ext_nid = isset($node) ? $node->nid : 0;
+  $ext_sid = isset($submission) && isset($submission->sid) ? $submission->sid : 0;
+  $ext_eid = isset($email) && isset($email['eid']) ? $email['eid'] : 0;
+  $extkey = $ext_nid .'-'. $ext_sid .'-'. $ext_eid;
+  if (!isset($ext_replacements[$extkey])) {
+    // Generate unsafe replacements even if they are not needed; there will
+    // surely be a future call to _webform_filter_values() in which they are.
+    $ext_replacements[$extkey] = module_invoke_all('webform_tokens', $node, $submission, $email);
+
+    // If non-arrays or values with unknown keys were returned, just disregard.
+    if (empty($ext_replacements[$extkey]['safe']) || !is_array($ext_replacements[$extkey]['safe'])) {
+      $ext_replacements[$extkey]['safe'] = array();
+    }
+    if (empty($ext_replacements[$extkey]['unsafe']) || !is_array($ext_replacements[$extkey]['unsafe'])) {
+      $ext_replacements[$extkey]['unsafe'] = array();
+    }
+
+    // Add '%' to the beginning of each token if not previously defined.
+    foreach ($ext_replacements[$extkey] as $safety => $ext_replacement) {
+      foreach ($ext_replacement as $key => $value) {
+        if (substr($key, 0, 1) != '%') {
+          $ext_replacements[$extkey][$safety]['%'. $key] = $value;
+          unset($ext_replacements[$extkey][$safety][$key]);
+        }
+      }
+    }
+  }
+
   // Make a copy of the replacements so we don't affect the static version.
-  $safe_replacements = $replacements['safe'];
+  // Rules:
+  // - Restrict replacements for anonymous users. Not all tokens can be used
+  //   because they may expose session or other private data to other users when
+  //   anonymous page caching is enabled.
+  // - 'external' replacements take precedence over replacements defined here.
+  // - for 'external' replacements,
+  //   - if unsafe replacements are allowed, unsafe replacements take precedence
+  //     over safe ones in case their tokens are equal.
+  //   - if unsafe replacements are disallowed, the safe replacements are taken.
+  // (For 'local' replacements, this is different:
+  //   - if unsafe replacements are allowed, safe ones take precedence over
+  //     unsafe ones => the tokens are replaced by the 'safe' value.
+  //   - if unsafe replacements are disallowed, safe ones are _deleted_, so
+  //     the tokens are always replaced by an empty string.
+  //   Not that we'd ever notice, because obviously there will be no key clashes
+  //   in anything we define here.)
+  $safe_replacements = array();
+  if ($user->uid || $allow_anonymous) {
+    $safe_replacements = $ext_replacements[$extkey]['unsafe'];
+  }
+  $safe_replacements += $ext_replacements[$extkey]['safe'];
+  $safe_replacements += $replacements['safe'];
 
-  // Restrict replacements for anonymous users. Not all tokens can be used
-  // because they may expose session or other private data to other users when
-  // anonymous page caching is enabled.
   if ($user->uid || $allow_anonymous) {
     $safe_replacements += $replacements['unsafe'];
     if (isset($replacements['email'][$format])) {
@@ -2910,6 +2958,10 @@ function _webform_filter_values($string, $node = NULL, $submission = NULL, $emai
       $string = preg_replace('/\\' . $token . '\[\w+\]/', '', $string);
     }
   }
+  if (!$user->uid && !$allow_anonymous && !empty($ext_replacements[$extkey]['unsafe'])) {
+    $keys = array_keys($ext_replacements[$extkey]['unsafe']);
+    $string = str_replace($keys, array_fill(0, count($keys), ''), $string);
+  }
 
   return $strict ? _webform_filter_xss($string) : $string;
 }
@@ -3094,6 +3146,19 @@ function theme_webform_token_help($groups = array()) {
     }
   }
 
+  $items = module_invoke_all('webform_token_help', $node);
+  if (!empty($items)) {
+    // As $items is a multi-level array (since it's returned by
+    // module_invoke_all()), we can just add keys to the first level.
+    $items += array(
+      '#title' => t('Token values provided by other modules'),
+      '#type' => 'fieldset',
+      '#collapsible' => TRUE,
+      '#collapsed' => FALSE
+    );
+    $output .= drupal_render($items);
+  }
+
   $fieldset = array(
     '#title' => t('Token values'),
     '#type' => 'fieldset',
